State Management within React Functional Components with hooks

We’ll look at managing component state and local variables

Rajesh Naroth
8 min readAug 26, 2019

Functional Component State variables

React functional components are plain JavaScript functions. It is not possible to persist state in local variables as these are initialized every time the function is evaluated. Thus to maintain state inside the function, React provides several hooks:

useState()

useState() hook allows you create and mange a state variable that can be a simple JavaScript primitive or an object. Typically this is how you would do it.

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()

useReducer() is used when you’d rather modify state via reducers and actions. An “actions up, data down” approach is recommended for managing complex state. This way, instead of sprinkling your state mutation logic throughout your component/container, it is tucked away inside a reducer. Here is a useReducer() example from the React site:

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()

Outside the legacy use of refs for hooking on to the DOM tree, use useRef() to keep a mutable value inside your functional component. Very similar to instance fields in classes. Here is the usage:

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()

What if you want to access state outside your component? What if you want to share it with other components? This is where useContext() becomes useful. In fact, you can create a simple store within your app and scope it to a specific part of your render component tree. All you have to do is to expose your state and dispatch to the components via useContext(). Redux operates by creating a single global state tree. IMO, if your state isn’t needed globally, don’t put it there. Keep it local to your component. For example, The filter/search components for a table of data do not need to store their state in redux. They must be “scoped” locally via useState()/useReducer() and useContext().

props

It is important to remember that props, passed in as function arguments from the component owner are also part of the component state. A functional component will refresh any time it receives new props (means, its old and new props fail referential equality).

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

Function internal variables will get initialized during each execution. Thus it is important to keep them optimized. Costly computations should be cached. memoization is a recommended pattern.

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

useMemo()

useMemo returns a memoized value from the provided input function.

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()

Sometimes you have to pass callback functions into child components. If the parent functional component uses regular functions to do this, it sends a different function reference each time. This prompts the child to re-render more than it needs to.

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

useState and useReducer works for small use cases. To scale your app, you will need a larger abstraction. There are several solutions available such as Redux, MobX etc. Redux has been the most popular one. Let’s look at its pros and cons.

Redux Pros

  • A read-only state only mutated via reducers
  • 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

Now, why should I abandon one of the most beloved libraries for React state management? Because of the first principle Redux is based on.

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

You might want to checkout Slide #37 here.

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()

A React application usually can be divided into multiple features. I believe that architecturally, your application features and its state should be managed in isolation and co-located. With co-location, a feature’s state is kept close to its functionality as much as possible, it is sometimes organized with in a single folder. Each feature should manage only its own data. Any required global state should be injected into the feature “container”. A container is a React Component that contains business logic and is responsible for orchestrating the feature components. It doesn’t concern itself much with presentation except composing them in a layout.

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

In one of my recent projects at HPE, this is what worked for us. Essentially, we have two classes of state:

Global State

Global State is sacred. It must be approached with respect since it is where you can create the worst regressions. You could store auth, access, routing, theme, translation hooks etc in global state. These are mostly static, initialized during bootstrapping. There shouldn’t be any state properties that mutates periodically because very component in your app will depend on the global state. When it changes, it triggers recalculations or re-rendering of all the visible React components.

Feature State

Ideally, your application should be composed of features with clear state boundaries. Carefully organized type files, api helpers and transformation functions can help bring down duplication. Creating isolated state for features have a lot of benefits:

  • 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

The idea is to manage your state via reducers and expose them via a Context:

  • 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

Recoil is Facebook’s new experimental state management library. It has a small API footprint. It also avoids the issue of component tree refresh within nested context providers. I am yet to explore Recoil and will update on it in another article.

Summary

For functional components:

  • 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.

--

--