Storing information, showing it and giving users the ability to interact with it in some way, shape, or form is the core of every application. The quality of the application is often measured, based on the level of interactivity and responsiveness provided.
In React, when talking about storing something, the first thing that comes to mind is a state. However, as the application grows, it’ll have more and more information to store, so it’s important to manage the state in a clean and readable way that is not only performant but also scalable .
50 shades of state
From opening modal or handling forms to storing user’s information (like email and password), state comes in many shades, thus it is important to divide it into two categories - server state and client state :
-
server state - like the name suggests is anything stored on the server (for example user’s email and password).
-
client state - to put it simply it’s everything else that is not needed to be stored on a server, for example whether modal is opened or closed.
It goes without saying, but it’s crucial that both states work together and are in sync, for example when a user updates email, which is stored on a server, the client state should react to that change somehow f.e showing a confirmation snackbar.
In this blog post I will tackle client state only so It’s not unnecessary bloated and easier to digest.
Built-in tools for Client State
React gives us multiple built-in ways of managing an application’s state with the help of hooks.
useState
const [state, setState] = useState<TState>(initialState);
It’s probably the first react hook anyone trying to learn React gets accustomed to. It’s straightforward and easy to use as it gives a possibility to simply retrieve and update some information.
It’s great for storing simple, in more professional language we would call them primitive , values like booleans, strings or number. However it’s still possible to store more complex data structures like arrays or objects, but it’s important to remember that state in React is immutable .
When working with just useState, we can find ourselves calling multiple setStates in a single function, for example, clearing form state:
const reset = () => {
setEmail('');
setUserName(''):
setPassword('');
setConfirmedPassword('');
}
React is smart and batches multiple sequential setStates into singular re-render, so it will not have performance implications to have sequential setStates functions, but it can get hard to manage.
And for that reason React has another way for managing state:
useReducer
const reducer = (action: TAction, state: TState): TState => {
switch(action.type) {
...
}
}
...
const [state, dispatch] = useReducer(reducer, initialState);
...
More advanced hook that is useful when working with complex state that have values depending on one another, providing a more redux-like implementation.
A good example would be managing the cart state. As we add items to the cart we also need to update the total price of the order. Instead of calling two separate setStates from useState we can just dispatch an action to handle both things at the same time .
Limitations of useState and useReducer
Both useState and useReducer are good for managing state, but their limitations can emerge as the application grows in size. Especially when:
-
01.
components have similar functionalities (e.g., forms, or modals), so with using just useState and useReducer app can become harder to maintain, due to lots of duplicated code,
-
02.
there are instances where state needs to be shared between multiple components that are not necessarily descendant of one another, and with just useState or useReducer and one-directional data flow in React it can lead to a prop drilling,
prop drilling - passing data through nested components, even those that are not dependent on that state.
Custom hooks abstractions
The first state management that can be introduced in any react app is custom hooks , by extracting common functionalities and then reusing it anywhere in the codebase, for example useForm :
interface FormValues {
[key: string]: string;
};
type ChangeHandler = (event: ChangeEvent<HTMLInputElement>) => void;
type SubmitHandler = (values: FormValues) => void;
interface UseFormProps {
initialValues: FormValues;
onSubmit: SubmitHandler;
};
export const useForm = ({ initialValues, onSubmit }: UseFormProps) => {
const [values, setValues] = useState<FormValues>(initialValues);
const handleChange: ChangeHandler = useCallback(
(event) => {
const { name, value } = event.target;
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
}, []);
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// some validation rules here ...
onSubmit(values);
}, []);
return {
values,
handleChange,
handleSubmit,
};
};
It is a good practice to wrap any function inside a custom hook in useCallback to prevent unwanted re-renders and behaviors.
By extracting common form functions and making the hook generic it’s now re-usable in every component that requires it. Additionally extending the logic by adding more functionalities to a form can be done in only one place - the hook itself.
What’s important to note is that each component that uses the hook will have a new instance of the state - state from custom hooks is not shared between components , to make sure that state is shared we need to use something else.
Let’s give it some more context…
To prevent prop drilling we can use another built-in feature of React - Context API.
It is a powerful tool that allows moving state outside of components tree and access or update it in any component inside of its provider. Handling dark mode is a perfect use case.
const DarkModeContext = createContext<{
isDarkMode: boolean;
toggleDarkMode: () => void;
}>({
isDarkMode: true,
toggleDarkMode: () => null;
});
export const DarkModeProvider = ({ children }: PropsWithChildren) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const toggleDarkMode = useCallback(() => setIsDarkMode(prev => !prev), []);
return (
<DarkModeContext.Provider value={{
isDarkMode,
toggleDarkMode
}}>
{children}
</DarkModeContext.Provider>
);
};
export const useDarkMode = () => useContext(DarkModeContext);
It is a common pattern to use context together with custom hooks, so that it's not required to import useContext and context itself in every component.
However, on the react documentation is stated, that one should be very mindful when choosing to use context. More often it is just better to send props multiple levels down the component tree, as it helps visualize the data flow within the app.
So what are some drawbacks of using Context API?
-
It requires some boilerplate to create a context.
-
Any change to the state within context will trigger re-render of any component that is wrapped with provider (so its best to go as deep in the component tree as possible).
-
It can get messy in figuring out which components are wrapped in which contexts when there are many providers in different places within the app.
Third party joins the chat
There are plenty of additional third-party libraries that can help with managing the state within the app.
Redux could be one of the most known example of such library, however it can introduce some unnecessary complexity as it requires a lot of boilerplate code with setting up store, reducers, selectors and actions making most apps over engineered (and can only get more complex as middleware like redux-thunk is added into the mix).
Thankfully, nowadays there are more modernized tools to choose from, that could be a better fit to specific needs.
I wanted to highlight one specific state management library that I have come to know and love in couple of projects I’ve been working on recently:
Jotai
Jotai means "state" in Japanese
A Light-weight atomic state manager that can be used as a replacement for React’s Context API. It’s simple to implement with little to none boilerplate required. Simply:
import { atom } from 'jotai'
const darkModeAtom = atom(false)
and that’s it. Atom can be accessed by any component with a help of Jotai hooks:
function CompA() {
// to access getter and setter
const [isDarkMode, setDarkMode] = useAtom(darkModeAtom);
}
function CompB() {
// to access just the atom's value
const isDarkMode = useAtomValue(darkModeAtom);
}
function CompC() {
// to just access the setter for the atom
const setDarkMode = useSetAtom(darkModeAtom);
}
It also fixes issue with Context API redundant re-renders - just components that are using the atom will rerender when that specific atom updates.
When using Context sometimes there is a need to have some on mount functionality, and jotai also provides a solution with onMount property, that’s similar to React’s built-in useEffect hook:
const anAtom = atom(...)
anAtom.onMount = (setAtom) => {
// on mount logic e.g. adding event listener
// return optional onUnmount function e.g. removing event listener
return () => { ... }
}
Jotai also gives developer a full control over atoms read (getting) and write (setting) behaviors by using read / write function:
const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(readFunc)
const derivedAtomWithReadWrite = atom(readFunc, writeFunc)
const derivedAtomWithWriteOnly = atom(null, writeFunc)
which allows to create private atoms that can be changed in only specific ways:
const privateCountAtom = atom(0)
export const publicCountAtom = atom(
(get) => get(privateCountAtom),
(get, set, action) => {
switch(action.type) {
case 'init':
case 'reset':
set(privateCountAtom, 10);
break;
case 'inc':
set(privateCountAtom, (prev) => prev + 1);
break;
default:
get(privateCountAtom);
break;
}
}
)
publicCountAtom.onMount = (setAtom) => {
setAtom({ type: 'init' })
}
Jotai is also compatible with Server-side rendering , but it does require additional step of wrapping the app with Provider .
NextJS (app directory)
// Providers.tsx
'use client'
import { PropsWithChildren } from 'react';
import { Provider } from 'jotai';
export default function Providers({ children }: PropsWithChildren) {
return (
<Provider>
{children}
</Provider>
);
};
// layout.tsx (app directory)
import { PropsWithChildren } from 'react';
import Providers from '@/providers'
export default function RootLayout({ children }: PropsWithChildren) {
return (
<html lang="en">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
};
You can learn more about Jotai from it’s nicely prepared documentation .
What goes around comes around...
For most modern applications, where keeping UI and client state in sync with server state is already solved by libraries like react-query or swr , there is very little state left to manage solely by the client.
Keeping that in mind more often than not, using just the built-in React hooks and Context API with custom hooks will be good enough as it makes your client future-proofed by preventing issues with third party packages, and can make your bundle size more lean.
Still, handling state on a client is a battle like any other, so it’s good to know your options for when you may need them ;).