From dev to prod logo
Published on

The ultimate guide to mastering state in React and saying goodbye to inconsistencies and bugs 🐞 !

7 min read - 1232 words
Authors
  • avatar
    Name
    CĂŠdric RIBALTA
    Twitter
post image

Why Reducing State in Your React Components Can Prevent Major Bugs 🐛

As a developer, you’ve likely reviewed a Pull Request (PR) or a Merge Request (MR) and noticed a React component with too many contradictory state variables. This common issue complicates application logic and increases the likelihood of bugs. Let’s explore why it’s crucial to reduce the number of states in your React components and how to achieve this.

Keep It Simple, It’s a Win 🎯

The React documentation warns against having too many state variables. Each piece of state is a “moving part.” The more “moving parts” you have, the more complex your application logic becomes… and complexity is a breeding ground for bugs.

Imagine a simple example:

const [isEmpty, setIsEmpty] = useState(true)
const [isTyping, setIsTyping] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)

Looking at this code snippet, you quickly see that the component state could become inconsistent. Imagine that isTyping and isSubmitting are both true. This paradox would be impossible to handle in the user interface, as the component couldn’t be both in “typing” and “submitting” modes simultaneously.

Understanding the Difference Between Imperative and Declarative UI 🤔

In a traditional application, each user interaction is often thought of as a direct manipulation: enabling, disabling, showing, or hiding elements. This is what we call an imperative approach. For example, imagine the user interacting with a form:

  • When the input field contains text, the "Submit" button becomes enabled.
  • When "Submit" is clicked, the form becomes disabled, and a spinner appears.
  • If the network request succeeds, the form disappears, and a thank-you message appears.
  • If the request fails, an error message appears, and the form becomes active again for another attempt.

In imperative programming, you must give specific instructions for each step of this interaction. This quickly becomes hard to manage, especially in complex applications.

React, however, adopts a declarative approach. Instead of manipulating each component directly, you declare the desired final state. React handles the necessary transitions to update the UI. It’s like telling a taxi driver your destination instead of dictating every turn. By adopting this approach, you make your code clearer and easier to maintain.

And if your components are pure (with no side effects), you can be sure that the component will always display the right state, whatever the user's interactions.

The Risks of Contradictory States ⚠️

When duplicating or multiplying state variables for the same visual behavior, you expose yourself to several problems:

  • Inconsistent state: Variables can conflict.
  • Difficult maintenance: The more state variables there are, the harder the component is to read and maintain.
  • Hard-to-detect bugs: Unsynced states can lead to unexpected behaviors.

Managing State Changes Based on User and Machine Inputs ⚙️

In React, state changes can be triggered by two types of inputs:

  1. User inputs: such as typing text or clicking a button.
  2. Machine inputs: like a network response or the end of a timeout.

For example, let’s look at how each action changes the state in a form component:

  • Text input** (human input): as the user types, the state changes from isEmpty to isTyping.
  • Clicking on “Send ”** (human input): this triggers the switch to the isSubmitting state.
  • Network response successful** (machine input): the state changes to success, and the thank-you message is displayed.
  • Network response failed** (machine input): status changes to error, and an error message is displayed.

These steps allow you to structure changes in a clear and maintainable way. With this method, you can anticipate all possible visual and functional "steps" of the component before even creating the underlying logic, identifying all possible states: Empty, Typing, Submitting, Success, and Error.

How to Effectively Manage State in Your Components 💡

To tackle these issues, it’s essential to minimize and centralize state. Here are some strategies from the React documentation:

  1. Eliminate Derived States
    If a state variable can be calculated from another, it probably isn’t necessary. For instance, isEmpty could be determined by answer.length === 0 instead of having a separate state variable.

  2. Group Related States
    Use a single state variable to represent multiple interdependent visual states. For example, instead of five boolean variables (isTyping, isSubmitting, isSuccess, etc.), create a status variable with values like 'typing', 'submitting', or 'success':

const [status, setStatus] = useState('typing') // 'typing', 'submitting', 'success'
  1. Avoid State Duplication
    State duplications often lead to inconsistencies. If information is already available through another variable, avoid creating new state. For example, isError could be replaced with error !== null.

Before/After Refactoring Example ✨

Let’s look at a complete example to illustrate state refactoring in a component:

Before:

const [answer, setAnswer] = useState('')
const [isEmpty, setIsEmpty] = useState(true)
const [isTyping, setIsTyping] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)

After refactoring :

const [answer, setAnswer] = useState('')
const [status, setStatus] = useState('typing') // 'typing', 'submitting', 'success'
const [error, setError] = useState(null)

This refactoring reduces the state to the essentials and makes the code much more readable. You can be sure that these three variables are necessary for the component’s functionality, as each is directly linked to the component’s visual behaviors.

Next Step: Extracting State Logic into a Reducer 🛠️

Once you have simplified and centralized the state in your component, the next step is to extract this state logic into a reducer. Using a reducer helps to structure and organize state changes more clearly, especially when the application grows more complex and state transitions multiply.

In React, useReducer is similar to useState, but it is particularly suited for managing multiple interdependent state variables. It uses a reducer function, which takes the current state and an action as input and returns the new state.

Imagine our form, where possible states are typing, submitting, success, and error. Here’s a simple example of a reducer to manage these state transitions.

import { useReducer } from 'react'

const initialState = {
  answer: '',
  status: 'empty', // 'typing', 'submitting', 'success', 'error'
  error: null,
}

function formReducer(state, action) {
  switch (action.type) {
    case 'TYPING':
      return { ...state, status: 'typing', answer: action.payload }
    case 'SUBMITTING':
      return { ...state, status: 'submitting' }
    case 'SUCCESS':
      return { ...state, status: 'success', answer: '' }
    case 'ERROR':
      return { ...state, status: 'error', error: action.payload }
    default:
      return state
  }
}

Then you can use this reducer in your component to reset the state and manage transitions :

const [state, dispatch] = useReducer(formReducer, initialState)

// Exemple d’utilisation dans un gestionnaire d’événements
function handleSubmit() {
  dispatch({ type: 'SUBMITTING' })

  // Simulation d’une requête réseau
  fakeApiCall()
    .then(() => dispatch({ type: 'SUCCESS' }))
    .catch((error) => dispatch({ type: 'ERROR', payload: error.message }))
}

Why Use a Reducer? 🚀

Using a reducer, you gain readability and centralization. State transition logic is clearly defined in a single function, making transitions predictable. This is especially useful for avoiding contradictory states, as each action is explicitly managed, simplifying debugging and enhancing maintainability.

This approach also offers better scalability. If new states or transitions are added to the application, you can integrate them directly into the reducer without risking fragmented state management across multiple functions or hooks.

By applying this technique, you bring the state management of your component to a level of clarity and robustness that’s especially valuable for larger applications and collaborative projects. 🎉

In Conclusion

Reducing the number of state variables in your React components can transform your code into a cleaner, more readable, and less error-prone component. The key is to keep it simple and only retain what is essential. By following these principles, you improve not only your application’s reliability but also make life easier for those who will read and maintain your code. ✨