BattlefyBlogHistoryOpen menu
Close menuHistory

How to escape React Hooks Hell

Ronald Chen October 5th 2021

When learning React Hooks, it seems like black magic. How the heck does useState retains its value? Over time, some learn the hook incantations to get stuff done. Even fewer eventually understand how hooks actually work.

But before then, it’s so easy to make a mess with React Hooks. In this article I will be showing some common issues as people start to really use React Hooks.

Unnecessary useEffect to initialize setState

One of the first incantations people learn is using useEffect to run some initialization code once per mount. This leads people to abuse useEffect to set the initial value for useState. But the useEffect is redundant in this case. useState can take an initial value or function.

Unnecessary useEffect with function

// BEFORE
const [state, setState] = useState();

useEffect(() => {
  setState(calculateInitialState());
}, []);


// AFTER
const [state, setState] = useState(calculateInitialState);

Unnecessary useEffect with value

// BEFORE
const [state, setState] = useState();

useEffect(() => {
  setState('loading');
}, []);


// AFTER
const [state, setState] = useState('loading');

Note, if the initial value comes from an async function, then there is no choice but to use useEffect. But what if the initial value is costly and depends on a prop value?

Unnecessary useEffect and useState for computed value

Sometimes there is a costly function that needs to be computed based off of a prop value. The naïve solution is to trigger the computation with useEffect with the prop value as the dependency and store the result with useState, but this can be simplified down to a single useMemo.

// BEFORE
const [costlyValue, setCostlyValue] = setState();

useEffect(() => {
  setCostlyValue(computedCostlyValue(props.someParam));
}, [props.someParam]);


// AFTER
const costlyValue = useMemo(() => computedCostlyValue(props.someParam), [props.someParam]);

Again, if the computed valued is an async function, then an useEffect is required.

Never use objects or arrays as dependencies

One of the most mystifying concepts with React Hooks is the dependency list. Why does it matter? When objects or arrays used as dependencies, it seems to just work, but this actually introduced a performance bug. React only uses triple equals === to check for dependency changes, thus it is not suitable for objects nor arrays.

When objects or arrays are used as dependencies, it effectively always re-runs the hook.

const BrokenComponent = (props) => {
  useEffect(() => {
    console.log('BrokenComponent called useEffect');
  }, [props]); // THIS IS ALWAYS WRONG!

  return <code>{JSON.stringify(props)}</code>;
};

const GoodComponent = (props) => {
  useEffect(() => {
    console.log('GoodComponent called useEffect');
  }, [JSON.stringify(props)]); // Cheesy fix, better to explicitly list all props

  return <code>{JSON.stringify(props)}</code>;
};

See this in action with a full demo: https://stackblitz.com/edit/react-5xghb4

Don’t useMemo entire inner components

Once people understand how useMemo can be used to improve performance, it becomes easy to be used poorly. Sometimes the initialization logic for components become expensive, and it’s tempting to wrap the whole declaration of the component with useMemo, but the correct thing is to only wrap useMemo around the costly part.

// Good useMemo usage
const NormalComponent = ({ count }) => {
  const costlyValue = useMemo(() => costlyFunction(count), [count]);
  return <p>NormalComponent {costlyValue}</p>;
};

export default ({ count }) => {
  // Slow component
  const SlowComponent = () => {
    const costlyValue = costlyFunction(count);
    return <p>SlowComponent {costlyValue}</p>;
  };

  // Bad useMemo usage
  const MemoizedComponent = useMemo(() => () => {
    const costlyValue = costlyFunction(count);
    return <p>MemoizedComponent {costlyValue}</p>;
  }, [count]);

  return <>
    <SlowComponent />
    <MemoizedComponent />
    <NormalComponent count={count} />
  </>;
};

But wait, isn’t this a contrived example since SlowComponent is an inner component? Well yes, which leads to…

Avoid inner components

Inner components are often introduced as a means to avoid passing props to immediate children. They do have their place when the parent/child component is very tightly coupled and the child component doesn’t make sense on its own.

As that may be, don’t use an inner component due to pure laziness of passing props. Extracting inner components makes it easier to test and reuse. It makes it easier to read the code of the parent component without having to keep in mind which variable are leaking in scope into the inner component.

// BAD
export default ({ count }) => {
  const Label = () => {
    return <code>{count}</code>;
  };
  
  return <Label />;
};


// GOOD
const Label = ({ count }) => {
  return <code>{count}</code>;
};

export default ({ count }) => {
  return <Label count={count} />;
};

But what if you need to pass a lot of props? One might run into prop drilling…

useContext instead of prop drilling

In large React apps, the component tree becomes tall. Often there are props that need to be passed down through several levels in the component tree and it becomes a bunch of busy work as the intermediate levels just pass the prop along. This is called prop drilling and best way to avoid this is to flatten the component tree using props.children.

However that isn’t always possible or desired. Another solution is useContext. This is commonly used handle “global” options like locale, theme, logged in user, etc.

In this example, we have 3 components, where the Link component requires the locale to get a translated version of the text, but there is an intermediate Navigation component. Here we see the Navigation component just passes the locale along. This is a short example, but in the real world, there can be many more intermediate levels.

We solve this problem by creating a LocaleContext. The LocaleContext is used in the App component to define the value for the rest of the tree and in Link with useContext. Notice Navigation component is none the wiser!

// BEFORE
const App = () => {
  const [locale, setLocale] = useState('english');
  return <Navigation locale={locale} />;
};

const Navigation = ({ locale }) => {
  return <Link locale={locale} href="/about">About</Link>;
};

const Link = ({ locale, href, children }) => {
  return <a href={href}>{translate(locale, children)}</a>;
};
  
// AFTER
const LocaleContext = React.createContext();
const App = () => {
  const [locale, setLocale] = useState('english');
  return <LocaleContext.Provider value={locale}>
    <Navigation />
  </LocaleContext.Provider>;
};

const Navigation = () => {
  return <Link href="/about">About</Link>;
};

const Link = ({ href, children }) => {
  const locale = useContext(LocaleContext);
  return <a href={href}>{translate(locale, children)}</a>;
};

But what happens when you need to override a context value? Do we need to add OverrideLocaleContext?

Override context are unnecessary

It may seem like a context can only hold one value, but really it’s just a reference. The real value is held in the Provider. Multiple providers can be nested and useContext will return the value to the closest Provider.

// BAD
const LocaleContext = React.createContext();
const OverrideLocaleContext = React.createContext()

const App = () => {
  const [locale, setLocale] = useState('english');
  return <LocaleContext.Provider value={locale}>
    <Navigation />
  </LocaleContext.Provider>;
};

const Navigation = () => {
  return <OverrideLocaleContext.Provider value="french">
    <Link href="/href">About</Link>
  </OverrideLocaleContext.Provider>;
};

const Link = ({ href, children }) => {
  const locale = useContext(LocaleContext);
  const overrideLocale = useContext(OverrideLocaleContext);
  return <a href={href}>{translate(overrideLocale || locale, children)}</a>;
};

  
// GOOD
const LocaleContext = React.createContext();

const App = () => {
  const [locale, setLocale] = useState('english');
  return <LocaleContext.Provider value={locale}>
    <Navigation />
  </LocaleContext.Provider>;
};

const Navigation = () => {
  return <LocaleContext.Provider value="french">
    <Link href="/href">About</Link>
  </LocaleContext.Provider>;
};

const Link = ({ href, children }) => {
  // uses closest Provider value, which is french
  const locale = useContext(LocaleContext);
  return <a href={href}>{translate(locale, children)}</a>;
};

Consider another issue, in order to use this translate function, we would need to keep repeating ourselves with useContext. We can do better.

Extract repeated hook usage into a custom hook

There is no one way to organize contexts, but here is how I usually do it. I’ll also show you show I would convert the usage of translate into a custom hook that is also performant.

locale.js

import {createContext, useContext} from 'react';

const LocaleContext = createContext();

export const ProvideLocale = Locale.Provider;

export const useLocale = () => {
  const locale = useContext(LocaleContext);
  return locale;
};

translation.js

import {useMemo} from 'react';
import {useLocale} from './locale';

function translate(locale, englishKey) {..}

export const useTranslation = (englishKey) => {
  const locale = useLocale();
  return useMemo(() => translate(locale, englishKey), [locale, englishKey]);
};

component.js

import {useTranslation} from './translation';

export const Link = ({ href, children }) => {
  const translation = useTranslation(children);
  return <a href={href}>{translation}</a>;
};

Avoid re-renders by reducing useCallback dependencies

React component re-rendering is an advanced topic and I won’t do it full justice here. But I will try to illustrate a small example of it. By default, React will re-render the entire component tree and this leads to problems on very large sites. One optimization is to only re-render parts of the component tree that have changed.

React.memo is used to reduce the re-rendering by enhancing components to only re-render if any of the props have changed. When one tries to use React.memo they will quickly wonder why it doesn’t seem to work. For non-trivial components, callback handlers such as onClick are often used. The function props passed into the component are also checked for changes by React.memo. Function equality is not possible aside from checking for identity.

In English, React.memo uses triple equals === to check for changes for each prop and functions are never triple equals === unless they are the same function.

The solution is to use the useCallback hook, but one needs to be careful about how to use it as the naïve approach can lead to returning a different function each time anyway. This would effectively fail to achieve the intention of avoiding re-renders with React.memo.

One way to apply useCallback correctly is to remove the dependency that was causing the new function to be returned. This is becoming unintelligible, but is easier to understand with a code example.

// React.memo caches the functional component if all the props are triple equals ===
const Increment = React.memo(({ caller, onClick }) => {
  console.log(`${caller} button rendered`);
  return <button onClick={onClick}>Increment</button>;
});

const BadComponent = () => {
  const [count, setCount] = useState(0);
  // declared functions never triple equals ===, even if its the same code
  // ie. (() => {}) === (() => {}) is false,
  // but when const x = () => {}; x === x is true
  // increment is different render to render
  const increment = () => setCount(count + 1);

  return <>
    <h2>BadComponent</h2>
    <Increment caller="BadComponent" onClick={increment} />
    <p>{count}</p>
  </>;
};

const StillBadComponent = () => {
  const [count, setCount] = useState(0);
  // useCallback always returns a new function since count changes
  const increment = useCallback(() => setCount(count + 1), [count]);

  return <>
    <h2>StillBadComponent</h2>
    <Increment caller="StillBadComponent" onClick={increment} />
    <p>{count}</p>
  </>;
};

const GoodComponent = () => {
  const [count, setCount] = useState(0);
  // removed count dependency by passing a function into setCount
  // increment is always the same function as dependency array is now empty
  const increment = useCallback(() => setCount((count) => count + 1), []);

  return <>
    <h2>GoodComponent</h2>
    <Increment caller="GoodComponent" onClick={increment} />
    <p>{count}</p>
  </>;
};

Run this for yourself in a full demo: https://stackblitz.com/edit/react-1r1rmg

The long game

I hope some of these patterns have helped you escape your own mini React Hooks Hell, but keep in mind there are far more pitfalls than I could ever possibly describe. The long game is to understand how the React Hook and render algorithm actually works.

Do you want to discover more React anti-patterns and their solutions? You’re in luck, Battlefy is hiring.

2024

  • MLBB Custom Lobby Feature
    Stefan Wilson - April 16th 2024

2023

2022

Powered by
BATTLEFY