Combining useState and useReducer

Loading

Combining these two hooks allows you to manage complex state logic while maintaining simple local state where appropriate. Here’s a comprehensive guide to effectively using them together.

Basic Patterns

1. Local UI State with Global State

function UserProfile() {
  // Local UI state
  const [isEditing, setIsEditing] = useState(false);
  const [tempName, setTempName] = useState('');

  // Global state managed by reducer
  const [state, dispatch] = useReducer(userReducer, initialState);

  const handleSave = () => {
    dispatch({ type: 'UPDATE_NAME', payload: tempName });
    setIsEditing(false);
  };

  return (
    <div>
      {isEditing ? (
        <>
          <input 
            value={tempName} 
            onChange={(e) => setTempName(e.target.value)} 
          />
          <button onClick={handleSave}>Save</button>
        </>
      ) : (
        <>
          <h2>{state.name}</h2>
          <button onClick={() => {
            setTempName(state.name);
            setIsEditing(true);
          }}>
            Edit
          </button>
        </>
      )}
    </div>
  );
}

2. Derived State

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, initialCart);
  const [couponCode, setCouponCode] = useState('');

  // Derived state from both reducer state and local state
  const totalWithDiscount = useMemo(() => {
    const subtotal = cart.items.reduce((sum, item) => sum + item.price, 0);
    const discount = couponCode === 'SAVE10' ? subtotal * 0.1 : 0;
    return subtotal - discount;
  }, [cart.items, couponCode]);

  return (
    <div>
      {/* Cart items rendering */}
      <input 
        value={couponCode}
        onChange={(e) => setCouponCode(e.target.value)}
        placeholder="Enter coupon code"
      />
      <p>Total: ${totalWithDiscount.toFixed(2)}</p>
    </div>
  );
}

Advanced Patterns

1. State Machines with useReducer

function formReducer(state, action) {
  switch (action.type) {
    case 'INPUT_CHANGE':
      return { ...state, [action.field]: action.value };
    case 'SUBMIT_START':
      return { ...state, status: 'submitting', error: null };
    case 'SUBMIT_SUCCESS':
      return { ...state, status: 'success' };
    case 'SUBMIT_ERROR':
      return { ...state, status: 'error', error: action.error };
    default:
      return state;
  }
}

function RegistrationForm() {
  const [formState, dispatch] = useReducer(formReducer, {
    email: '',
    password: '',
    status: 'idle', // 'idle' | 'submitting' | 'success' | 'error'
    error: null
  });

  // Local UI state not relevant to form submission
  const [showPassword, setShowPassword] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });

    try {
      await api.register(formState.email, formState.password);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ type: 'SUBMIT_ERROR', error });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={formState.email}
        onChange={(e) => dispatch({
          type: 'INPUT_CHANGE',
          field: 'email',
          value: e.target.value
        })}
      />
      <input
        type={showPassword ? 'text' : 'password'}
        value={formState.password}
        onChange={(e) => dispatch({
          type: 'INPUT_CHANGE',
          field: 'password',
          value: e.target.value
        })}
      />
      <button type="button" onClick={() => setShowPassword(!showPassword)}>
        {showPassword ? 'Hide' : 'Show'} Password
      </button>
      <button 
        type="submit" 
        disabled={formState.status === 'submitting'}
      >
        {formState.status === 'submitting' ? 'Registering...' : 'Register'}
      </button>
      {formState.error && <p className="error">{formState.error.message}</p>}
    </form>
  );
}

2. Combining with Context API

const UserContext = createContext();

function UserProvider({ children }) {
  // User data managed by reducer
  const [state, dispatch] = useReducer(userReducer, initialUserState);

  // Local UI state for provider
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const loadUser = async () => {
      setIsLoading(true);
      try {
        const user = await api.getUser();
        dispatch({ type: 'SET_USER', payload: user });
      } catch (error) {
        dispatch({ type: 'SET_ERROR', payload: error });
      } finally {
        setIsLoading(false);
      }
    };

    loadUser();
  }, []);

  const value = useMemo(() => ({
    ...state,
    isLoading,
    dispatch
  }), [state, isLoading]);

  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

function useUser() {
  const context = useContext(UserContext);
  if (!context) throw new Error('useUser must be used within UserProvider');
  return context;
}

3. Asynchronous Actions with Local State

function productsReducer(state, action) {
  switch (action.type) {
    case 'FETCH_SUCCESS':
      return { ...state, products: action.payload, error: null };
    case 'FETCH_ERROR':
      return { ...state, products: [], error: action.payload };
    default:
      return state;
  }
}

function ProductsPage() {
  const [state, dispatch] = useReducer(productsReducer, {
    products: [],
    error: null
  });

  // Local UI state for pagination
  const [page, setPage] = useState(1);
  const [perPage, setPerPage] = useState(10);

  useEffect(() => {
    const fetchProducts = async () => {
      try {
        const data = await api.getProducts(page, perPage);
        dispatch({ type: 'FETCH_SUCCESS', payload: data });
      } catch (error) {
        dispatch({ type: 'FETCH_ERROR', payload: error.message });
      }
    };

    fetchProducts();
  }, [page, perPage]);

  return (
    <div>
      <div className="pagination">
        <button onClick={() => setPage(p => Math.max(1, p - 1))}>
          Previous
        </button>
        <span>Page {page}</span>
        <button onClick={() => setPage(p => p + 1)}>
          Next
        </button>
        <select 
          value={perPage} 
          onChange={(e) => setPerPage(Number(e.target.value))}
        >
          {[5, 10, 20, 50].map(num => (
            <option key={num} value={num}>{num} per page</option>
          ))}
        </select>
      </div>

      {state.error ? (
        <p className="error">{state.error}</p>
      ) : (
        <ProductList products={state.products} />
      )}
    </div>
  );
}

Performance Optimization

1. Memoizing Reducer Dispatches

function TodoApp() {
  const [todos, dispatch] = useReducer(todosReducer, []);
  const [filter, setFilter] = useState('all');

  // Memoize filtered todos
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => {
      if (filter === 'all') return true;
      if (filter === 'active') return !todo.completed;
      return todo.completed;
    });
  }, [todos, filter]);

  // Memoize actions to prevent unnecessary re-renders
  const actions = useMemo(() => ({
    addTodo: text => dispatch({ type: 'ADD_TODO', text }),
    toggleTodo: id => dispatch({ type: 'TOGGLE_TODO', id }),
    deleteTodo: id => dispatch({ type: 'DELETE_TODO', id })
  }), []);

  return (
    <>
      <TodoFilter filter={filter} setFilter={setFilter} />
      <TodoList todos={filteredTodos} actions={actions} />
    </>
  );
}

2. Combining with useCallback

function ComplexForm() {
  const [formData, dispatch] = useReducer(formReducer, initialState);
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Memoize submit handler
  const handleSubmit = useCallback(async () => {
    setIsSubmitting(true);
    try {
      await api.submitForm(formData);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ type: 'SUBMIT_ERROR', error });
    } finally {
      setIsSubmitting(false);
    }
  }, [formData]);

  return (
    <form>
      {/* Form fields */}
      <button 
        onClick={handleSubmit} 
        disabled={isSubmitting}
      >
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

TypeScript Implementation

1. Typed Reducer with Local State

type Todo = {
  id: string;
  text: string;
  completed: boolean;
};

type TodoAction =
  | { type: 'ADD_TODO'; text: string }
  | { type: 'TOGGLE_TODO'; id: string }
  | { type: 'DELETE_TODO'; id: string };

function todosReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now().toString(), text: action.text, completed: false }];
    case 'TOGGLE_TODO':
      return state.map(todo => 
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case 'DELETE_TODO':
      return state.filter(todo => todo.id !== action.id);
    default:
      return state;
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todosReducer, []);
  const [newTodoText, setNewTodoText] = useState('');

  const addTodo = () => {
    if (newTodoText.trim()) {
      dispatch({ type: 'ADD_TODO', text: newTodoText });
      setNewTodoText('');
    }
  };

  return (
    <div>
      <input
        type="text"
        value={newTodoText}
        onChange={(e) => setNewTodoText(e.target.value)}
        placeholder="New todo"
      />
      <button onClick={addTodo}>Add</button>
      <TodoList todos={todos} dispatch={dispatch} />
    </div>
  );
}

When to Use Each Approach

useStateuseReducer
Simple stateComplex state logic
Independent valuesRelated state values
No complex transitionsState transitions
Local component stateGlobal or shared state
Direct updatesUpdates with clear actions

Best Practices for Combining Them

  1. Separation of Concerns:
  • Use useReducer for business logic and data state
  • Use useState for UI state and local component state
  1. Colocation:
  • Keep state as close to where it’s needed as possible
  • Lift state up only when multiple components need it
  1. Performance:
  • Memoize derived state with useMemo
  • Memoize callbacks with useCallback when passing to optimized children
  1. Type Safety:
  • Use TypeScript to define action types and state shapes
  • Create helper functions for action creators
  1. Testing:
  • Test reducers in isolation
  • Test components with mock state and dispatches

By strategically combining useState and useReducer, you can create React applications with maintainable state management that scales well with complexity while remaining performant.

Leave a Reply

Your email address will not be published. Required fields are marked *