State Management within React Functional Components with hooks

We’ll look at managing component state and local variables

Functional Component State variables

useState()

const [isAuthenticated, setIsAuthenticated] = useState(false)

This does two things.

  1. Your components can bind to the value of isAuthenticated.
  2. The only way to mutate the state and its binding is to call the function setIsAuthenticated()

For simple types, using const keyword protects the state from direct mutation. For object types, nothing stops you from updating the object’s properties. However, this will not have any effect on data binding. React will not recompute/re-render your component..

useReducer()

const initialState = { count: 0 };const reducer = (state, action) => {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
};
const increment = () => ({ type: "increment" });
const decrement = () => ({ type: "decrement" });
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(decrement())}>-</button>
</>
);
}

You modify your state via the dispatch() function. Anytime, the state changes, the component will refresh. If you are debating when to use useState() vs useReducer, this article might help:

when used in conjunction with React’s Context API, you can manage your state very elegantly. More on that follows below.

useRef()

const myPromise = useRef();const onClick = () => {
myPromise.current = fetch('/myapi');
}

The difference between useState() and useRef() is that a component will not refresh when you mutate the ref’s current value.

useContext()

props

props must be treated as immutable. Just like function arguments, you should never modify props from within the functional component. Doing so will not trigger a refresh. Since objects are passed via references, you may end up creating hard to track bugs. Modifying a function should be treated as a code smell.

Optimizing local variables for performance

Thankfully, React provides you a couple of hooks to do this. Both of them requires you to pass down an array of dependencies.

useMemo()

function computeExpensiveValue(a, b) {
// Let's say, this imaginary %|% operator is very expensive
return a %|% b ;
}
const compute = useMemo(() => computeExpensiveValue(a, b), [a, b]);
compute(1, 2); // call computeExpensiveValue(1, 2)
compute(1, 2); // no change in arguments. Return cached value
compute(2, 3); // call computeExpensiveValue(2, 3)

Memoization is an overkill for simple computations. You will barely see any improvement.

useCallback()

const Parent = () => {
const [counter, setCounter] = useState(0);
const onClickHandler = () => {
console.log(`counter = ${counter}`);
}

return <Child onClick={onClickHandler} />;
}

Here the onClickHandler() function fails referential equality because a different function is created during every Parent render. This causes the Child to do re-render even though onClickHandler is the pretty much the same function. To avoid this, we use the useCallBack() hook.

const Parent = () => {
const [counter, setCounter] = useState(0);
const onClickHandler = useCallback(() => {
console.log(`counter = ${counter}`);
}, [counter]);

return <Child onClick={onClickHandler} />;
}

useCallBack() makes sure that the function passed down to <Child> component only changes if its dependency (counter) change.

State management solutions

Redux Pros

  • A single store that can be serialized and restored via hydration
  • A good amount of tooling available around the framework
  • Support for middleware that can intercept action dispatches and do fun things with it.

Redux Cons

The state of your whole application is stored in an object tree within a single GLOBAL store.

  1. This leads to developers coding around Redux to organize and namespace the store into smaller bits that feeds into different features of their app. In the process we also end up developing a sizable suite of Redux helper functions.
  2. In Redux, actions are dispatched to all the reducers provided within combineReducers(). There will be a performance hit when you have a ton of reducers managing different parts of your store. It also seems quite unnecessary.
  3. Hydrating an app with a saved Redux tree sounds great but the amount of state data you need to do this will be extremely complex. When you start managing everything in one place, things can go wrong very quickly.
  4. The biggest issue I have with Redux is the global nature of it. Software applications can get immensely complex. Splitting your state into flattened list of reducers with combineReducers() is not enough. It doesn’t model well in an application with deeply nested features. Yes, you can engineer your way around this, which points to #1.
  5. Redux has a fairly high learning curve, there is a whole site dedicated to it.

Why global state is bad

Global variables are free game to any part of your app. All your 27 developers can mutate them by design or by mistake. When your application is asynchronous, it gets even worse.

Global state and its mutations should be carefully designed. Accidental state change should be avoided as much as possible. Redux is not immune to this because you can modify your state by dispatching an action from anywhere in the application. By design or accident.

Keeping state where it belongs with useContext()

We’ll use two out of the three principles from Redux:

  • an immutable state
  • mutations via reducers

useReducer() comes in handy here. Actions, action object creators and reducers are pure functions. This makes them very unit testable.

Designing your state

Global State

Feature State

  • You bring down regression risk. One feature will not break another.
  • A feature’s state is local. It is not mutable from outside.
  • You can scale development teams easier by assigning features to small teams or individuals.
  • Less time spent on merge conflicts
  • You can cleanly split your code. A heavy feature that is lazy loaded will not affect the app performance outside itself.
  • A feature properly isolated can be reused and composed in other contexts. In fact, this is the essence of React.

Creating islands of states with useReducer and useContext

  • create your reducer and actions to manage your feature state. Bind dispatch function to your action functions.
const actions = dispatch => ({
increase: () => dispatch(increment()),
decrease: () => dispatch(decrement())
});
const [reducerState, dispatch] = React.useReducer(reducer, initialState);const reducerActions = actions(dispatch);
  • set the feature context value to contain the reducer state and action dispatch functions.
const ContainerContext = React.createContext(initialState);
// initialState is the default value of the context
const context = {
state: { ...reducerState },
actions: { ...reducerActions }
};

Wrap your feature inside the context Provider.

<ContainerContext.Provider value={context}>
{props.children}
</ContainerContext.Provider>

You expose the context via a custom hook.

export const useContainerContext =
() => React.useContext(ContainerContext);

Any component within the context Provider will now be able to access and subscribe to the context state.

const context = useContainerContext();
const { count } = context.state;
const { increase, decrease } = context.actions;

Check out the code Sandbox.

Recoil

Summary

  • useState() helps you manage state variables. The component will refresh if the state changes.
  • useReducer() helps you manage state Redux style - actions up, data down.
  • useRef() helps you create state variables that do not trigger a refresh when its value changes
  • useContext() helps you share a state value in a component tree/branch
  • props are part of the component state passed in externally from the component’s container.
  • useMemo() helps you memoize a heavy computation
  • useCallback() helps you keep a callback’s reference the same until its dependencies change, avoiding unnecessary re-renders.
  • a combination of useReducer() and useContext() can help you isolate state and make it available where it matters.

Frontend Architect, San Jose, CA