Originally published at https://www.developerway.com. The website has more articles like this đ
Itâs impossible to have a conversation on how to write performant React code without having an article or two on Context. And itâs such a controversial topic! There are so many prejudices and rumours surrounding it. Context is evil! React re-renders everything for no reason when you use Context! Sometimes I have a feeling that developers treat Context like itâs a magic gremlin, that randomly and spontaneously re-renders the entire app for its own amusement.
In this article I do not intend to convince anyone that we should ditch our beloved state management libraries in favour of Context. They exist for a reason. The main goal here is to de-mystify Context and provide a few interesting coding patterns, that could help minimise Context-related re-renders and improve your React apps performance. And as a nice bonus, the code will look cleaner and more understandable as a result.
Letâs start the investigation with implementing some real-life app, and see where this will take us.
Letâs implement a form in React
Our form is going to be quite complicated, it would consist, to begin with, from:
- a âPersonal infoâ section, where people can set some personal information, i.e name, email, etc
- a âValue calculationâ section where people can set their currency preferences, their preferred discount, add some coupons, etc
- the selected discount should be highlighted in the Personal section in a form of an emoji (donât ask, the designer has a weird sense of humour)
- an âActionsâ section with action buttons (i.e. âSaveâ, âResetâ, etc)
The âdesignâ looks like this:
To make things more interesting, weâre also going to pretend that âselect countryâ and âdragging barâ components are âexternalâ libraries that we installed as a package. So we can only use them through API, but have no influence on whatâs inside. And weâre going to use the âslowâ version of the countries select, that we implemented in the previous performance investigation.
Now itâs time to write some code. Letâs start with the components structure of the app. I know this form will quickly become quite complicated, so I want to separate it into smaller, more contained components right away.
At the root Iâll have my main Form
component, which will render the three required sections:
const Form = () => {
return (
<>
<PersonalInfoSection />
<ValueCalculationsSection />
<ActionsSection />
</>
);
};
âPersonal infoâ section will then render three more components: the discount emoji, input for the name and countries select
const PersonalInfoSection = () => {
return (
<Section title="Personal information">
<DiscountSituation />
<NameFormComponent />
<SelectCountryFormComponent />
</Section>
);
};
All three of them will contain the actual logic of those components (the code of them will be below), and the Section
just encapsulates some styles.
âValue calculationâ section will have just one component (for now), the discount bar:
const ValueCalculationSection = () => {
return (
<Section title="Value calculation">
<DiscountFormComponent />
</Section>
);
};
And âActionsâ section will have just one button for now as well: the save button with onSave callback.
const ActionsSection = ({ onSave }: { onSave: () => void }) => {
return (
<Section title="Actions">
<button onClick={onClick}>Save form</button>
</Section>
);
};
Now the interesting part: we need to make this form interactive. Considering that we have a single âSaveâ button for the entire form, and different sections would need data from other sections, the natural place for the state management is at the root, in the Form
component. Weâll have 3 pieces of data there: Name, Country and Discount, a way to set all three of them, and a way to âsaveâ it:
type State = {
name: string;
country: Country;
discount: number;
};
const Form = () => {
const [state, setState] = useState<State>(defaultState as State);
const onSave = () => {
// send the request to the backend here
};
const onDiscountChange = (discount: number) => {
setState({ ...state, discount });
};
const onNameChange = (name: string) => {
setState({ ...state, name });
};
const onCountryChange = (country: Country) => {
setState({ ...state, country });
};
// the rest as before
};
And now we need to pass the relevant data and callbacks to the components that need it. In our PersonalInfoSection
:
- the
DiscountSituation
component should be able to show the emoji based ondiscount
value. - the
NameFormComponent
should be able to controlname
value - the
SelectCountryFormComponent
should be able to set the selectedcountry
Considering that those components are not rendered in Form
directly, but are children of PersonalInfoSection
, time to do some prop drilling đ
DiscountSituation
will accept discount
as a prop:
export const DiscountSituation = ({ discount }: { discount: number }) => {
// some code to calculate the situation based on discount
const discountSituation = ...;
return <div>Your discount situation: {discountSituation}</div>;
};
NameFormComponent
will accept name
and onChange
callback:
export const NameFormComponent = ({ onChange, name }: { onChange: (val: string) => void; name: string }) => {
return (
<div>
Type your name here: <br />
<input onChange={() => onChange(e.target.value)} value={name} />
</div>
);
};
SelectCountryFormComponent
will accept onChange
callback:
export const SelectCountryFormComponent = ({ onChange }: { onChange: (country: Country) => void }) => {
return <SelectCountry onChange={onChange} />;
};
And our PersonalInfoSection
would have to pass all of them from its parent Form
component to its children:
export const PersonalInfoSection = ({
onNameChange,
onCountryChange,
discount,
name,
}: {
onNameChange: (name: string) => void;
onCountryChange: (name: Country) => void;
discount: number;
name: string;
}) => {
return (
<Section title="Personal information">
<DiscountSituation discount={discount} />
<NameFormComponent onChange={onNameChange} name={name} />
<SelectCountryFormComponent onChange={onCountryChange} />
</Section>
);
};
And the same story with ValueCalculationSection
: it needs to pass onDiscountChange
and discount
value from Form
component to its child:
export const ValueCalculationsSection = ({ onDiscountChange }: { onDiscountChange: (val: number) => void }) => {
console.info('ValueCalculationsSection render');
return (
<Section title="Value calculation">
<DiscountFormComponent onDiscountChange={onDiscountChange} />
</Section>
);
};
And the DiscountFormComponent
just uses the âexternalâ library DraggingBar
to render the bar and catch the changes via the callback it gives:
export const DiscountFormComponent = ({ onDiscountChange }: { onDiscountChange: (value: number) => void }) => {
console.info('DiscountFormComponent render');
return (
<div>
Please select your discount here: <br />
<DraggingBar onChange={(value: number) => onDiscountChange(value)} />
</div>
);
};
And, the render of our Form
component would look like this:
const Form = () => {
return (
<div>
<PersonalInfoSection onNameChange={onNameChange} onCountryChange={onCountryChange} discount={state.discount} name={state.name} />
<ValueCalculationsSection onDiscountChange={onDiscountChange} />
<ActionsSection onSave={onSave} />
</div>
);
};
Quite a bit of code, but finally done đ Want to take a look at the result? See the codesandbox.
Unfortunately, the result is much worst than youâd expect from a composition of a few components and a simple state đ Try to type your name in the input, or drag the blue bar - both of them are lagging even on a fast laptop. With CPU throttling they are basically unusable. So, what happened?
The form performance investigation
First of all, letâs take a look at the console output there. If I type a single key in the Name
input, Iâll see:
Form render
PersonalInfoSection render
Section render
Discount situation render
NameFormComponent render
SelectCountryFormComponent render
ValueCalculationsSection render
Section render
DiscountFormComponent render
ActionsSection render
Section render
Every single component in our form re-renders on every keystroke! And the same situation is with the dragging - on every mouse move the entire form and all its components re-renders themselves. And we already know, that our SelectCountryFormComponent
is very slow, and there is nothing we can do with its performance. So the only thing that we can do here, is to make sure it doesnât re-render on every keypress or mouse move.
And, as we know, components will re-render when:
- state of a component changed
- parent component re-renders
And this is exactly what is happening here: when the value in an input changes, we propagate this value up to the root Form
component through our chain of callbacks, where we change the root state, which triggers re-render of the Form
component, which then cascades down to every child and child of a child of this component (i.e. all of them).
To fix it, we could, of course, sprinkle some useMemo
and useCallback
in strategic places and call it a day. But that just brushes the problem under the rug, not actually solving it. When in the future we introduce another slow component, the story will repeat itself. Not to mention that it will make the code much more complicated and harder to maintain. In the ideal world, when I type something in the Name
component, I want only the NameFormComponent
and components that actually use the name
value to re-render, the rest should just sit idle there and wait for their turn to be interactive.
And React actually gives us a perfect tool to do that - Context
!
Adding Context to the form
As per React docs, context provides a way to pass data through the component tree without having to pass props down manually at every level. If, for example, we extract our Form state into Context, we can get rid of all the props weâve been passing through intermediate sections like PersonalInfoSection
and use state directly in the NameFormComponent
and DiscountFormComponent
. The data flow then would look something like this:
To achieve this, first weâre creating the Context
itself, which will have our state and the API to manage this state (i.e. our callbacks):
type State = {
name: string;
country: Country;
discount: number;
};
type Context = {
state: State;
onNameChange: (name: string) => void;
onCountryChange: (name: Country) => void;
onDiscountChange: (price: number) => void;
onSave: () => void;
};
const FormContext = createContext<Context>({} as Context);
Then we should move all the state logic, that we had in Form
, in the FormDataProvider
component, and attach the state and callbacks to the newly created Context
:
export const FormDataProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<State>({} as State);
const value = useMemo(() => {
const onSave = () => {
// send the request to the backend here
};
const onDiscountChange = (discount: number) => {
setState({ ...state, discount });
};
const onNameChange = (name: string) => {
setState({ ...state, name });
};
const onCountryChange = (country: Country) => {
setState({ ...state, country });
};
return {
state,
onSave,
onDiscountChange,
onNameChange,
onCountryChange,
};
}, [state]);
return <FormContext.Provider value={value}>{children}</FormContext.Provider>;
};
Then expose the hook for other components to use this Context without accessing it directly:
export const useFormState = () => useContext(FormContext);
And wrap our Form
component into the FormDataProvider
:
export default function App() {
return (
<FormDataProvider>
<Form />
</FormDataProvider>
);
}
After that, we can get rid of all the props throughout the app, and use the required data and callbacks directly in the components where itâs needed via useFormState
hook.
For example, our root Form
component will turn into just this:
const Form = () => {
// no more props anywhere!
return (
<div className="App">
<PersonalInfoSection />
<ValueCalculationsSection />
<ActionsSection />
</div>
);
};
And NameFormComponent
will be able to access all the data like this:
export const NameFormComponent = () => {
// accessing the data directly right where it's needed!
const { onNameChange, state } = useFormState();
const onValueChange = (e: ChangeEvent<HTMLInputElement>) => {
onNameChange(e.target.value);
};
return (
<div>
Type your name here: <br />
<input onChange={onValueChange} value={state.name} />
</div>
);
};
Take a look at the full code in this codesandbox. Donât forget to appreciate how clean it looks now when there is no more mess of props everywhere!
What about the performance of the new form?
From the performance perspective weâre still not there yet: typing the name and dragging the bar is still lagging. But if I start typing in the NameFormComponent
, in the console I will now see this:
Discount situation render
NameFormComponent render
SelectCountryFormComponent render
DiscountFormComponent render
ActionsSection render
Section render
Half of the components now donât re-render, including our parent Form
component. This is happening because of how Context works: when a Context value changes, every consumer of this context will re-render, regardless of whether they use the changed value or not. But also, those components that are bypassed by Context wonât be re-rendering at all. Our re-renders flow now looks like this:
And now, if we look closely at our components implementation, in particular SelectCountryComponent
, which is the wrapper around the slow âexternalâ component, weâll see that it doesnât actually use the state
itself. All it needs is the onCountryChange
callback:
export const SelectCountryFormComponent = () => {
const { onCountryChange } = useFormState();
console.info('SelectCountryFormComponent render');
return <SelectCountry onChange={onCountryChange} />;
};
And this gives us an opportunity to try out a really cool trick: we can split the state
part and the API
part under our FormDataProvider
.
Splitting the state and the API
Basically, what we want to do here is to decompose our âmonolithâ state into two âmicrostatesâ đ .
Instead of one context that has everything weâd need 2 contexts, one for data, one for API:
type State = {
name: string;
country: Country;
discount: number;
};
type API = {
onNameChange: (name: string) => void;
onCountryChange: (name: Country) => void;
onDiscountChange: (price: number) => void;
onSave: () => void;
};
const FormDataContext = createContext<State>({} as State);
const FormAPIContext = createContext<API>({} as API);
Instead of one context provider in our FormDataProvider
component, weâd again have two, where weâd pass our state directly to the FormDataContext.Provider
:
const FormDataProvider = () => {
// state logic
return (
<FormAPIContext.Provider value={api}>
<FormDataContext.Provider value={state}>{children}</FormDataContext.Provider>
</FormAPIContext.Provider>
);
};
And now the most interesting part, the api
value.
If we just leave it as it was before, the whole âdecompositionâ idea is not going to work because we still would have to rely on the state
as a dependency in the useMemo
hook:
const api = useMemo(() => {
const onDiscountChange = (discount: number) => {
// this is why we still need state here - in order to update it
setState({ ...state, discount });
};
// all other callbacks
return { onSave, onDiscountChange, onNameChange, onCountryChange };
// still have state as a dependency
}, [state]);
This will result in the api
value changing with every state update, which would lead to the FormAPIContext
triggering re-renders on every state update, which would make our split useless. We want our api
to stay constant regardless of the state
, so that consumers of this provider don't re-render.
Fortunately, there is another neat trick that we can apply here: we can extract our state into a reducer and instead of calling setState
in the callback we would just trigger a reducer action.
First, create actions and reducer itself:
type Actions =
| { type: 'updateName'; name: string }
| { type: 'updateCountry'; country: Country }
| { type: 'updateDiscount'; discount: number };
const reducer = (state: State, action: Actions): State => {
switch (action.type) {
case 'updateName':
return { ...state, name: action.name };
case 'updateDiscount':
return { ...state, discount: action.discount };
case 'updateCountry':
return { ...state, country: action.country };
}
};
Use reducer instead of useState
:
export const FormProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(reducer, {} as State);
// ...
};
And migrate our api
to dispatch
instead of setState
:
const api = useMemo(() => {
const onSave = () => {
// send the request to the backend here
};
const onDiscountChange = (discount: number) => {
dispatch({ type: 'updateDiscount', discount });
};
const onNameChange = (name: string) => {
dispatch({ type: 'updateName', name });
};
const onCountryChange = (country: Country) => {
dispatch({ type: 'updateCountry', country });
};
return { onSave, onDiscountChange, onNameChange, onCountryChange };
// no more dependency on state! The api value will stay the same
}, []);
And the final step: donât forget to migrate all the components that used useFormState
to useFormData
and useFormAPI
. For example, our SelectCountryFormComponent
will use onCountryChange
from the useFormAPI
hook, and will never re-render on the state change.
export const SelectCountryFormComponent = () => {
const { onCountryChange } = useFormAPI();
return <SelectCountry onChange={onCountryChange} />;
};
Take a look at the full implementation in this codesandbox. The typing and dragging bar are blazing fast now, and the only console output weâd see when we type something is this:
Discount situation render
NameFormComponent render
Only two components, since only those two use the actual state data. đ
Splitting state even further
Now, people with good design eyes or just careful readers might notice that I cheated a little bit. We donât pass the selected country to our âexternalâ SelectCountry
component, and it's stuck on the very first item in the list. In reality, the selected âlilacâ color should move to the country you click on. And the component actually allows us to pass it via activeCountry
. Technically, I can do it as simple as that:
export const SelectCountryFormComponent = () => {
const { onCountryChange } = useFormAPI();
const { country } = useFormData();
return <SelectCountry onChange={onCountryChange} activeCountry={country} />;
};
There is one problem with it though - as soon as I use useFormData
hook in a component, it will start re-rendering with the state changes, same as NameFormComponent
. Which in our case means weâll be back to the laggy experience on typing and dragging.
But now, since we already know how to split the data between different providers, nothing stops us from taking this to the next level and just splitting the rest of the state as well. Moar providers! đ
Instead of one unified context for State
weâll have three now:
const FormNameContext = createContext<State['name']>({} as State['name']);
const FormCountryContext = createContext<State['country']>({} as State['country']);
const FormDiscountContext = createContext<State['discount']>({} as State['discount']);
Three state providers:
<FormAPIContext.Provider value={api}>
<FormNameContext.Provider value={state.name}>
<FormCountryContext.Provider value={state.country}>
<FormDiscountContext.Provider value={state.discount}>{children}</FormDiscountContext.Provider>
</FormCountryContext.Provider>
</FormNameContext.Provider>
</FormAPIContext.Provider>
And three hooks to use the state:
export const useFormName = () => useContext(FormNameContext);
export const useFormCountry = () => useContext(FormCountryContext);
export const useFormDiscount = () => useContext(FormDiscountContext);
And now in our SelectCountryFormComponent
we can use useFormCountry
hook, and it will not be re-rendering on any changes other than country itself:
export const SelectCountryFormComponent = () => {
const { onCountryChange } = useFormAPI();
const country = useFormCountry();
return <SelectCountry onChange={onCountryChange} activeCountry={country} />;
};
Check this out in codesandbox: itâs still fast, and country is selectable. And the only thing weâll see in console output when we type something in the name input is:
NameFormComponent render
Bonus: external state management
Now, the question of whether this formâs state shouldâve been implemented with some state management library right away might cross some of your minds. And youâre maybe right. After all, if we look closely at the code, we just re-invented the wheel and implemented a rudimentary state management library, with selectors-like functionality for the state and separate actions to change that state.
But now you have a choice. Context is not a mystery anymore, with those techniques you can easily write performant apps with just pure Context if there is a need, and if you want to transition to any other framework, you can do it with minimal changes to the code. State management framework doesnât really matter when you design your apps with Context in mind.
We might as well move it to the good old Redux right now. The only things weâd need to do are: get rid of Context and Providers, convert React reducer to Redux store, and convert our hooks to use Redux selectors and dispatch.
const store = createStore((state = {}, action) => {
switch (action.type) {
case 'updateName':
return { ...state, name: action.payload };
case 'updateCountry':
return { ...state, country: action.payload };
case 'updateDiscount':
return { ...state, discount: action.payload };
default:
return state;
}
});
export const FormDataProvider = ({ children }: { children: ReactNode }) => {
return <Provider store={store}>{children}</Provider>;
};
export const useFormDiscount = () => useSelector((state) => state.discount);
export const useFormCountry = () => useSelector((state) => state.country);
export const useFormName = () => useSelector((state) => state.name);
export const useFormAPI = () => {
const dispatch = useDispatch();
return {
onCountryChange: (value) => {
dispatch({ type: 'updateCountry', payload: value });
},
onDiscountChange: (value) => dispatch({ type: 'updateDiscount', payload: value }),
onNameChange: (value) => dispatch({ type: 'updateName', payload: value }),
onSave: () => {},
};
};
Everything else stays the same and works exactly as we designed. See the codesandbox.
That is all for today, hope now Context
is not the source of mysterious spontaneous re-renders in your app, but a solid tool in your arsenal of writing performant React code âđŒ
...
Originally published at https://www.developerway.com. The website has more articles like this đ
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Top comments (7)
Hi Nadia, fantastic article.
I had one question regarding the section of the article where you introduced the reducer pattern in order to avoid having to use state in the dependencies list of the
useMemo
. Is there any reason usinguseReducer
as you have would be preferable to simply usingsetState(prevState => ...)
?I came here to ask the same thing. It does work!
Awesome article, Nadia!
Ha, perfect catch @kangweichan! Just haven't though about it đł It will also likely work for this scenario.
I just usually use reducers when the state becomes a bit big.
Context is just a tool with its own set of benefits and downsides, like any other tool. I wouldn't make strict generalisation when it should and shouldn't be used.
And I believe that the most important thing is to understand how tools like that work, so that it's possible to make an informed decision for every situation, without just relying on generalisations like "Context shouldn't be used for state management". It can and should if there is a problem that it solves better than other tools.
In real life more likely than not there will be a state management library already for a form like that. Or it will be not that big to cause any performance concerns even with props drilling through the entire form. So the use of Context there will not be necessary.
I certainly do not claim that we should use Context for everything. Just that sometimes it can be useful and that use is not limited to global things like theming. And that there are some patterns available that can mitigate its downsides.
Hi @lukeshiru , I appreciate your comment and the effort you put into your rework, this is really cool đ
Unfortunately, you "cheated" a little bit. One of the initial constrain in the example is that we're using the "CountriesSelect" component as-is, with all of its performance flaws. And you refactored that part đ. If you replace the countries select in your example with the original one, it will be as slow as the initial form example without context, which is exactly as you implemented - a bunch of stateless components with the state managed at the top.
This situation is specially designed to imitate the real life scenarios, when a developer needs to use an external library or just another team's component, which turns out to be very slow, but on which they have no influence and can only use them as a black box.
As for reducer in the provider - this is needed to remove direct dependency on state for the API part of the provider. Otherwise it will still re-render on every state change. And the similar pattern is actually described in React docs: beta.reactjs.org/learn/scaling-up-...
And finally, the main purpose of the article is to demystify how Context works and give developers confidence when using it. This form surely can be implemented in a million way, including Redux-based solution linked in the article as well đ
Very well written / well explained. Thank you.
Great Article đ