본문 바로가기
Dev/React

A Practical Guide to useReducer in React 19

by ArcticBear 2025. 3. 9.
반응형



 1. Introduction

In React applications, state management can become challenging when complex or multi-step updates are required. Using multiple useState hooks or deeply nested objects sometimes leads to scattered logic. useReducer addresses this by providing a predictable, reducer-driven approach—similar to Redux but localized to a component.

Key Benefits:

  1. Predictable: All state transitions flow through a single reducer function.
  2. Testable: Pure reducer logic is easy to test with various inputs/outputs.
  3. Maintainable: Clear separation of “what happened” (actions) and “how to update” (reducer logic).

 

 2. Basic Signature

The basic signature for useReducer is:

const [state, dispatch] = useReducer(reducer, initialArg, init?);
  1. reducer: A function (state, action) => newState describing how to transform current state based on an action.
  2. initialArg: The initial state value, or an argument passed to an optional initializer function.
  3. init? (Optional): A function for lazy initialization if you need to derive the starting state from initialArg.

Return Value:

  • state: The current, updated state after reducer actions.
  • dispatch: A function to send actions (e.g. dispatch({ type: "INCREMENT" })).

 

 3. When to Use useReducer

  1. Complex or Nested State: If multiple slices of state or objects get unwieldy in useState, a reducer can organize updates cleanly.
  2. Multiple Transition Types: When you have distinct ways of updating state (e.g., INCREMENT, DECREMENT, RESET), a reducer pattern centralizes them.
  3. Predictable State Flow: If you’re used to Redux or other state machines, useReducer follows a similar pattern of dispatching actions.
  4. Better Testing: You can unit-test your reducer function independently from React, ensuring correctness for each action type.

 4. Basic Example: Counter

A classic scenario is incrementing and decrementing a counter:

import React, { useReducer } from "react";

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;
    case "DECREMENT":
      return state - 1;
    case "RESET":
      return 0;
    default:
      return state;
  }
}

export default function Counter() {
  const [count, dispatch] = useReducer(counterReducer, 0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>Increment</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>Decrement</button>
      <button onClick={() => dispatch({ type: "RESET" })}>Reset</button>
    </div>
  );
}

Explanation:

  • useReducer(counterReducer, 0) initializes state to 0.
  • dispatch({ type: "INCREMENT" }) triggers the reducer logic, returning the new state.
  • Each button neatly specifies an action type, making updates straightforward.

 

 5. More Advanced Example: Form Management

Consider a multi-field form with validations:

import React, { useReducer } from "react";

const initialForm = {
  username: "",
  email: "",
  age: "",
  errors: {},
};

function formReducer(state, action) {
  switch (action.type) {
    case "UPDATE_FIELD": {
      const { field, value } = action.payload;
      return {
        ...state,
        [field]: value,
      };
    }
    case "SET_ERRORS": {
      return {
        ...state,
        errors: action.payload,
      };
    }
    case "RESET_FORM":
      return initialForm;
    default:
      return state;
  }
}

export default function UserForm() {
  const [formState, dispatch] = useReducer(formReducer, initialForm);

  function handleChange(e) {
    dispatch({
      type: "UPDATE_FIELD",
      payload: { field: e.target.name, value: e.target.value },
    });
  }

  function handleSubmit(e) {
    e.preventDefault();
    const errors = {};
    if (!formState.username) errors.username = "Username is required.";
    if (!formState.email.includes("@")) errors.email = "Email is invalid.";
    if (Object.keys(errors).length) {
      dispatch({ type: "SET_ERRORS", payload: errors });
      return;
    }
    // If no errors, submit or handle the data
    console.log("Form Submitted:", formState);
    dispatch({ type: "RESET_FORM" });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Username:
        <input
          name="username"
          value={formState.username}
          onChange={handleChange}
        />
        {formState.errors.username && <p>{formState.errors.username}</p>}
      </label>
      <label>
        Email:
        <input
          name="email"
          value={formState.email}
          onChange={handleChange}
        />
        {formState.errors.email && <p>{formState.errors.email}</p>}
      </label>
      <label>
        Age:
        <input
          name="age"
          value={formState.age}
          onChange={handleChange}
        />
      </label>
      <button type="submit">Submit</button>
      <button type="button" onClick={() => dispatch({ type: "RESET_FORM" })}>
        Reset
      </button>
    </form>
  );
}

Highlights:

  • formReducer handles all form-related actions, like field updates or error setting.
  • Action Types: UPDATE_FIELD, SET_ERRORS, RESET_FORM.
  • This structure keeps the form logic centralized and easier to maintain or test.

 

 6. Best Practices & Tips

  1. Keep Reducers Pure
    • No API calls or side effects in the reducer. If you need async logic, handle it outside and dispatch results.
  2. Meaningful Action Types
    • Use descriptive strings (e.g., SUBMIT_FORM, FETCH_SUCCESS) for clarity and future-proofing.
  3. Consider Constants
    • For large apps, define action type constants to avoid typos and keep them organized.
  4. useState vs. useReducer
    • If your state changes are minimal (only one or two fields), useState is usually simpler.
    • Move to useReducer when transitions multiply or you need a structured approach.
  5. Combine with useContext
    • If multiple components need access to this reducer-driven state, wrap it in a context provider.

 7. Potential Pitfalls

  1. Overkill for Simple Cases
    • Don’t force useReducer if a single useState suffices. Always weigh complexity vs. benefits.
  2. State Mutation
    • Never mutate the state directly in the reducer—always return a new object or array.
  3. Action Explosion
    • If you have too many action types, consider splitting your reducer or reorganizing logic.
  4. Large Data Structures
    • Cloning big objects or arrays in the reducer can get expensive. If performance becomes an issue, explore ways to optimize updates.

 

 8. Conclusion

useReducer in React 19 offers a predictable, testable, and organized way to manage state transitions. By centralizing update logic in a single reducer function, you clearly separate what happened (the action) from how it updates the state (the reducer).

Key Takeaways

  • Basic Signature:
     
const [state, dispatch] = useReducer(reducer, initialArg, init?);
  • Predictable Updates: Each dispatched action flows through the same logic.
  • Clean Separation: Keep side effects out of reducers—use them only for state transformations.
  • Great for Complex State: If your app’s state transitions grow in number or sophistication, useReducer can keep things manageable.

With a solid grasp of useReducer, you’ll be better equipped to handle complex forms, multi-step wizards, and advanced UI flows in your React 19 apps—without losing clarity or maintainability.

반응형