How to write performant React apps with Context

Nadia Makarevich - Feb 7 '22 - - Dev Community

Image description

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:

Image description

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 />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

“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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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 on discount value.
  • the NameFormComponent should be able to control name value
  • the SelectCountryFormComponent should be able to set the selected country

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

SelectCountryFormComponent will accept onChange callback:

export const SelectCountryFormComponent = ({ onChange }: { onChange: (country: Country) => void }) => {
  return <SelectCountry onChange={onChange} />;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Image description

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:

Image description

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);
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

Then expose the hook for other components to use this Context without accessing it directly:

export const useFormState = () => useContext(FormContext);
Enter fullscreen mode Exit fullscreen mode

And wrap our Form component into the FormDataProvider:

export default function App() {
  return (
    <FormDataProvider>
      <Form />
    </FormDataProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 };
  }
};
Enter fullscreen mode Exit fullscreen mode

Use reducer instead of useState:

export const FormProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(reducer, {} as State);
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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
}, []);
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

And three hooks to use the state:

export const useFormName = () => useContext(FormNameContext);
export const useFormCountry = () => useContext(FormCountryContext);
export const useFormDiscount = () => useContext(FormDiscountContext);
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: () => {},
  };
};
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .