We use cookies to enhance your experience on the site
CodeWorlds

Reducers

In more complex React applications, state management can become complicated. Sometimes the state update logic grows, operations on state become more complex, and the code becomes harder to understand and maintain. The useReducer hook comes to the rescue, offering a more organized way to manage complex state.

The useReducer hook

The

useReducer
hook is an alternative to
useState
, particularly useful when we have complex state logic or when the next state depends on the previous one. It is inspired by the Redux pattern and works on similar principles.

Basic syntax

1const [state, dispatch] = useReducer(reducer, initialState);

Where:

  • state
    is the current state
  • dispatch
    is the function used to send actions
  • reducer
    is a function that takes the current state and an action, and returns a new state
  • initialState
    is the initial state value

Simple useReducer example

1import React, { useReducer } from 'react';
2
3// Reducer function
4function counterReducer(state, action) {
5  switch (action.type) {
6    case 'INCREMENT':
7      return { count: state.count + 1 };
8    case 'DECREMENT':
9      return { count: state.count - 1 };
10    case 'RESET':
11      return { count: 0 };
12    default:
13      return state;
14  }
15}
16
17function CounterWithReducer() {
18  // Initial state
19  const initialState = { count: 0 };
20
21  // Using useReducer
22  const [state, dispatch] = useReducer(counterReducer, initialState);
23
24  return (
25    <div className="counter">
26      <p>Counter: {state.count}</p>
27
28      <button onClick={() => dispatch({ type: 'INCREMENT' })}>
29        Increment
30      </button>
31
32      <button onClick={() => dispatch({ type: 'DECREMENT' })}>
33        Decrement
34      </button>
35
36      <button onClick={() => dispatch({ type: 'RESET' })}>
37        Reset
38      </button>
39    </div>
40  );
41}

In this example:

  1. We define a
    counterReducer
    function that specifies how the state should change in response to different actions
  2. We use
    useReducer
    to manage the counter state
  3. We use the
    dispatch
    function to send actions (objects with a
    type
    field) that are processed by the reducer

Managing complex state

useReducer
truly shines when dealing with complex state where multiple fields can be updated in different ways. Let's consider an example of a spaceship control system:

1import React, { useReducer } from 'react';
2
3// Initial state definition
4const initialSpaceshipState = {
5  power: 100,
6  shields: 75,
7  weapons: 0,
8  lifeSupport: 100,
9  status: 'docked',
10  alerts: []
11};
12
13// Reducer function
14function spaceshipReducer(state, action) {
15  switch (action.type) {
16    case 'ALLOCATE_POWER':
17      // Allocate power from the main system to a subsystem
18      return {
19        ...state,
20        power: state.power - action.amount,
21        [action.system]: state[action.system] + action.amount
22      };
23
24    case 'CHANGE_STATUS':
25      return {
26        ...state,
27        status: action.status,
28        // Add alert about status change
29        alerts: [...state.alerts, {
30          id: Date.now(),
31          message: `Status changed to: ${action.status}`,
32          level: 'info'
33        }]
34      };
35
36    case 'POWER_FAILURE':
37      return {
38        ...state,
39        power: Math.max(0, state.power - action.amount),
40        alerts: [...state.alerts, {
41          id: Date.now(),
42          message: `Power failure! Lost ${action.amount}% power`,
43          level: 'critical'
44        }]
45      };
46
47    case 'DISMISS_ALERT':
48      return {
49        ...state,
50        alerts: state.alerts.filter(alert => alert.id !== action.alertId)
51      };
52
53    default:
54      return state;
55  }
56}
57
58function SpaceshipControl() {
59  const [ship, dispatch] = useReducer(spaceshipReducer, initialSpaceshipState);
60
61  const allocatePower = (system, amount) => {
62    if (ship.power >= amount) {
63      dispatch({
64        type: 'ALLOCATE_POWER',
65        system,
66        amount
67      });
68    } else {
69      alert('Insufficient power!');
70    }
71  };
72
73  return (
74    <div className="spaceship-control">
75      <h1>Ship control panel</h1>
76
77      <div className="status-display">
78        <p>Status: {ship.status}</p>
79        <button onClick={() => dispatch({ type: 'CHANGE_STATUS', status: 'orbiting' })}>
80          Enter orbit
81        </button>
82        <button onClick={() => dispatch({ type: 'CHANGE_STATUS', status: 'cruising' })}>
83          Start cruising
84        </button>
85      </div>
86
87      <div className="power-systems">
88        <h2>Onboard systems</h2>
89        <p>Main generator: {ship.power}%</p>
90
91        <div className="system">
92          <p>Shields: {ship.shields}%</p>
93          <button onClick={() => allocatePower('shields', 10)}>
94            Increase power (+10%)
95          </button>
96        </div>
97
98        <div className="system">
99          <p>Weapon systems: {ship.weapons}%</p>
100          <button onClick={() => allocatePower('weapons', 25)}>
101            Charge weapons (+25%)
102          </button>
103        </div>
104
105        <div className="system">
106          <p>Life support system: {ship.lifeSupport}%</p>
107          <button onClick={() => allocatePower('lifeSupport', 5)}>
108            Increase power (+5%)
109          </button>
110        </div>
111
112        <button onClick={() => dispatch({ type: 'POWER_FAILURE', amount: 20 })}>
113          Simulate power failure
114        </button>
115      </div>
116
117      <div className="alerts">
118        <h2>Alert center</h2>
119        {ship.alerts.length === 0 ? (
120          <p>No active alerts</p>
121        ) : (
122          <ul>
123            {ship.alerts.map(alert => (
124              <li key={alert.id} className={alert.level}>
125                {alert.message}
126                <button onClick={() => dispatch({ type: 'DISMISS_ALERT', alertId: alert.id })}>
127                  Dismiss
128                </button>
129              </li>
130            ))}
131          </ul>
132        )}
133      </div>
134    </div>
135  );
136}

In this more complex example:

  1. We have a multi-level state with different subsystems
  2. Actions can affect multiple aspects of the state simultaneously
  3. We have business logic validation (e.g., checking if there is enough power)
  4. We handle a list of alerts that changes over time

State structure

One of the advantages of using

useReducer
is the ability to better organize and structure the state. Here are some tips for designing state structure:

1. Grouping related data

1// Instead of a flat structure
2const initialState = {
3  missionName: 'Apollo',
4  missionStatus: 'planning',
5  crewCount: 3,
6  commanderName: 'Smith',
7  engineStatus: 'check',
8  fuelLevel: 100
9};
10
11// Better to group related data
12const betterInitialState = {
13  mission: {
14    name: 'Apollo',
15    status: 'planning'
16  },
17  crew: {
18    count: 3,
19    commander: 'Smith'
20  },
21  systems: {
22    engine: {
23      status: 'check',
24      fuelLevel: 100
25    }
26  }
27};

2. Data normalization

For collections of objects, it's often worth storing them as "normalized" data:

1// Instead of an array
2const missions = [
3  { id: 1, name: 'Apollo', status: 'completed' },
4  { id: 2, name: 'Artemis', status: 'planning' }
5];
6
7// Better to use an object with IDs as keys
8const normalizedMissions = {
9  byId: {
10    1: { id: 1, name: 'Apollo', status: 'completed' },
11    2: { id: 2, name: 'Artemis', status: 'planning' }
12  },
13  allIds: [1, 2]
14};

This makes it easier to update, add, and remove elements without having to iterate through the entire array.

3. Immutability

The reducer must always return a new state object, never modify the existing one. For deeply nested objects, libraries like immer can be helpful:

1import produce from 'immer';
2
3function complexReducer(state, action) {
4  switch (action.type) {
5    case 'UPDATE_CREW_MEMBER':
6      // Without immer this would be much more complicated
7      return produce(state, draft => {
8        const member = draft.crew.members.find(m => m.id === action.memberId);
9        if (member) {
10          member.status = action.status;
11        }
12      });
13
14    // other cases...
15
16    default:
17      return state;
18  }
19}

Combining Context API with useReducer

Combining Context API with

useReducer
is a powerful combination that allows managing global application state without the need for external libraries like Redux:

1import React, { createContext, useReducer, useContext } from 'react';
2
3// Create context
4const SpaceshipContext = createContext();
5
6// Reducer
7function spaceshipReducer(state, action) {
8  switch (action.type) {
9    case 'SET_COURSE':
10      return {
11        ...state,
12        destination: action.destination,
13        status: 'en route'
14      };
15    case 'ARRIVE':
16      return {
17        ...state,
18        location: state.destination,
19        destination: null,
20        status: 'arrived'
21      };
22    case 'REFUEL':
23      return {
24        ...state,
25        fuel: 100
26      };
27    default:
28      return state;
29  }
30}
31
32// Provider
33function SpaceshipProvider({ children }) {
34  const initialState = {
35    name: 'USS Enterprise',
36    status: 'docked',
37    location: 'Earth',
38    destination: null,
39    fuel: 75
40  };
41
42  const [state, dispatch] = useReducer(spaceshipReducer, initialState);
43
44  return (
45    <SpaceshipContext.Provider value={{ state, dispatch }}>
46      {children}
47    </SpaceshipContext.Provider>
48  );
49}
50
51// Hook for easy context access
52function useSpaceship() {
53  const context = useContext(SpaceshipContext);
54  if (context === undefined) {
55    throw new Error('useSpaceship must be used within a SpaceshipProvider');
56  }
57  return context;
58}
59
60// Component using context
61function NavigationControl() {
62  const { state, dispatch } = useSpaceship();
63
64  const destinations = ['Mars', 'Jupiter', 'Saturn', 'Alpha Centauri'];
65
66  return (
67    <div className="navigation-control">
68      <h2>Navigation control</h2>
69      <p>Current status: {state.status}</p>
70      <p>Location: {state.location}</p>
71      {state.destination && <p>Destination: {state.destination}</p>}
72
73      <div className="destination-selector">
74        <h3>Select destination:</h3>
75        {destinations.map(dest => (
76          <button
77            key={dest}
78            disabled={state.status === 'en route' || dest === state.location}
79            onClick={() => dispatch({ type: 'SET_COURSE', destination: dest })}
80          >
81            {dest}
82          </button>
83        ))}
84      </div>
85
86      {state.status === 'en route' && (
87        <button onClick={() => dispatch({ type: 'ARRIVE' })}>
88          Simulate arrival at destination
89        </button>
90      )}
91
92      <div className="fuel-control">
93        <p>Fuel level: {state.fuel}%</p>
94        <button
95          onClick={() => dispatch({ type: 'REFUEL' })}
96          disabled={state.status === 'en route' || state.fuel === 100}
97        >
98          Refuel
99        </button>
100      </div>
101    </div>
102  );
103}
104
105// Another component using the same context
106function ShipStatus() {
107  const { state } = useSpaceship();
108
109  return (
110    <div className="ship-status">
111      <h2>{state.name}</h2>
112      <p>Status: <span className={state.status}>{state.status}</span></p>
113      <p>Location: {state.location}</p>
114      {state.destination && <p>Destination: {state.destination}</p>}
115      <div className="fuel-gauge">
116        <div className="fuel-level" style={{ width: `${state.fuel}%` }}>
117          {state.fuel}%
118        </div>
119      </div>
120    </div>
121  );
122}
123
124// Main application
125function App() {
126  return (
127    <SpaceshipProvider>
128      <div className="spaceship-dashboard">
129        <h1>Spaceship control panel</h1>
130        <div className="dashboard-grid">
131          <ShipStatus />
132          <NavigationControl />
133        </div>
134      </div>
135    </SpaceshipProvider>
136  );
137}

In this example:

  1. We create a context and provider for the spaceship state
  2. We use
    useReducer
    to manage that state
  3. We create a custom hook
    useSpaceship
    for easier context access
  4. The
    NavigationControl
    and
    ShipStatus
    components share the same state
  5. Actions sent via
    dispatch
    update the state accessible throughout the application

This approach gives us many advantages:

  • A central place for state logic
  • Well-defined actions
  • Avoiding prop drilling
  • Component independence from each other (they communicate only through context)

Global state management

As the application becomes more complex, we may need more advanced solutions for global state management. Combining

useReducer
with Context API is a good start, but for truly large applications it's worth considering:

  1. Splitting state by domain - instead of one large context, create smaller contexts for different areas of the application:
1function App() {
2  return (
3    <AuthProvider>
4      <MissionProvider>
5        <UIProvider>
6          <NotificationProvider>
7            <MainApplication />
8          </NotificationProvider>
9        </UIProvider>
10      </MissionProvider>
11    </AuthProvider>
12  );
13}
  1. Middleware - add the ability to intercept and modify actions:
1function useReducerWithMiddleware(reducer, initialState, middlewares = []) {
2  const [state, dispatch] = useReducer(reducer, initialState);
3
4  const dispatchWithMiddleware = useCallback(
5    action => {
6      let modifiedAction = { ...action };
7
8      // Apply middleware
9      for (const middleware of middlewares) {
10        middleware(state, modifiedAction);
11      }
12
13      dispatch(modifiedAction);
14    },
15    [state, middlewares]
16  );
17
18  return [state, dispatchWithMiddleware];
19}
20
21// Example logging middleware
22const loggingMiddleware = (state, action) => {
23  console.log('Previous state:', state);
24  console.log('Action:', action);
25};
  1. Asynchronous actions - create a pattern for handling asynchronous actions:
1function MissionProvider({ children }) {
2  const [state, dispatch] = useReducer(missionReducer, initialState);
3
4  // Function handling asynchronous actions
5  const asyncDispatch = useCallback(async (action) => {
6    if (typeof action === 'function') {
7      await action(dispatch, () => state);
8    } else {
9      dispatch(action);
10    }
11  }, [state]);
12
13  return (
14    <MissionContext.Provider value={{ state, dispatch: asyncDispatch }}>
15      {children}
16    </MissionContext.Provider>
17  );
18}
19
20// Usage
21function MissionControls() {
22  const { dispatch } = useMission();
23
24  const startMission = () => {
25    // Asynchronous action
26    dispatch(async (dispatch, getState) => {
27      dispatch({ type: 'MISSION_LOADING' });
28
29      try {
30        const response = await fetch('/api/missions/start');
31        const data = await response.json();
32
33        dispatch({
34          type: 'MISSION_STARTED',
35          payload: data
36        });
37      } catch (error) {
38        dispatch({
39          type: 'MISSION_ERROR',
40          error: error.message
41        });
42      }
43    });
44  };
45
46  return (
47    <button onClick={startMission}>Start mission</button>
48  );
49}

Scaling applications

As the application scales, it's worth considering more advanced techniques:

  1. Split reducers - for large applications you can split the reducer into smaller functions and then combine them:
1function crewReducer(state, action) {
2  switch (action.type) {
3    case 'ADD_CREW_MEMBER':
4      return [...state, action.member];
5    //
6    default:
7      return state;
8  }
9}
10
11function shipSystemsReducer(state, action) {
12  switch (action.type) {
13    case 'UPDATE_ENGINE_STATUS':
14      return {
15        ...state,
16        engine: {
17          ...state.engine,
18          status: action.status
19        }
20      };
21    //
22    default:
23      return state;
24  }
25}
26
27// Combining reducers
28function rootReducer(state, action) {
29  return {
30    crew: crewReducer(state.crew, action),
31    systems: shipSystemsReducer(state.systems, action),
32    // other parts of state
33  };
34}
  1. Create "action creators" - functions that create actions:
1// Action creator
2const addCrewMember = (name, role) => ({
3  type: 'ADD_CREW_MEMBER',
4  member: { id: Date.now(), name, role }
5});
6
7// Usage
8dispatch(addCrewMember('Anna Nowak', 'Pilot'));
  1. Consider using libraries - for truly large applications, libraries like Redux, MobX, or zustand can offer more tools and patterns for managing complex state.

useReducer
combined with Context API provides a powerful solution for state management in React applications. It gives structure, predictability, and scalability, while remaining within the React ecosystem without the need to add external dependencies. It is particularly useful for medium and larger applications where state organization becomes a key challenge.

Go to CodeWorlds