Skip to content

Instantly share code, notes, and snippets.

@mathesond2
Last active January 25, 2024 09:26
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mathesond2/a5e96b7bb5c8001c101cee4e093fb28b to your computer and use it in GitHub Desktop.
Save mathesond2/a5e96b7bb5c8001c101cee4e093fb28b to your computer and use it in GitHub Desktop.
Notes on React Performance

React Performance

Notes From Steve Kinney's "React Performance" Frontend Masters Course

General

re: optimizations: "Start with a problem first, then solve it. dont go looking for problems."

"measure first before you optimize for performance. And then measure again."

profiler --> general --> check 'highlight updates when components render' to visually see what components render' (green border around element as long as you have devTools open)

performance --> settings icon --> CPU --> change throttling to see whats up with it for other kinds of devices/connection speeds

Thoughts

  • "not doing stuff is faster than doing stuff!" (Component Hierarchy and State Mgmt)
  • checking to see if you can skip doing stuff is sometimes less work than doing stuff (memoization)
  • Sometimes you can put off doing stuff (Suspense)
  • Maybe you do the urgent stuff now and less urgent stuff later? (Transition API - React 18)

React's Rendering cycle

  1. render phase (what changed, and react does the optimal changes to the DOM based on those changes, return the new JSX)
  2. commit phase - change the dom
  3. clean-up phase - this is where effects happen

"Did they parent change? If so, render it and its children."

Do these changes batch? ex: 2-5 state changes at the same time? That depends on the React version.

  • 17 and below = sometimes...in an event handler (onChange, onSubmit)...if it had a setTimeout or an async fn, it didnt.
  • 18+ = automatic batching. React collects all state changes, then batch processes them and does the commit/clean-up phases.

Tl;DR: In React 18, if you trigger multiple state changes in a single pass, React will batch them all up for your before the rendering process begins.

quick and dirty optimizing

  1. Consider component composition first.
  2. Stop rendering at a certain level in the tree ("where's the cost/benefit here of stopping these re-renders across component and children?")

Pushing State Down

Isolate state within individual components to try to ensure that its siblings aren't triggering unnecessary re-renders. i.e. "keep the component and its business to itself as much as possible."

"Lower a component's blast radius."

Take the expensive parts and isolate it from the others...separate the problem child from the rest of the group.

  • React.memo() - looks at props before passing to component, "are these props different? If not, dont bother the component. Otherwise, re-render."

^ remember though, this has a slight cost to it. What's happening in your code now maybe won't be the same 9 months from now.

Make sure the medicine's not worse than the disease!

Pulling Content Up

You can use children as a way to easily have a direct connection between grandparent-child.

useMemo and useCallback

  • useMemo() - if it was expensive to get this value or could trigger a render but is no different than last time..then use this fn. = for a VALUE
  • useCallback() - dont whip up a new fn if nothing changed from last time. = for a FUNCTION

Context

check this out:

export const ItemsContext = CreateContext({});

const ItemsProvider = ({ children }) => {
  const [items, dispatch] = useReducer(reducer, getInitialItems());

  return {
    <ItemsContext.Provider value={{items, dispatch}}>
      {children}
    </ItemsContext.Provider>
  }
}

THIS value={{items, dispatch}} will trigger a re-render to all components consuming the context, even the ones that just use dispatch bc when dispatched, items will change...all performance gains are erased here bc of the new object created.

So the question becomes..how to fix this?

Multiple. Contexts:

export const ActionsContext = CreateContext({});
export const ItemsContext = CreateContext({});

const ItemsProvider = ({ children }) => {
  const [items, dispatch] = useReducer(reducer, getInitialItems());

  return (
    <ActionsContext.Provider value={dispatch}>
      <ItemsContext.Provider value={items}>
        {children}
      </ItemsContext.Provider>
    </ActionsContext.Provider value={dispatch}>
  );
}

But again....this is NOT always the best choice, it's just A choice and really depends on your situation.

normalizing data

Sometimes deeply nested data structures that we get back from APIs can screw our existing performance optimizations and/or cause unnecessary re-renders themselves, so we can normalize the data beforehand so it plays nicely.

Think: "how could I make this data shape simpler to trigger less re-renders?"

Theres Normalizr for Redux, but realistically you can just do this yourself.

Suspense

AKA "What do you need to load the initial page?"

Suspense is a series of APIs for data fetching (not ready yet), component fetching.

Suspense = "let's give you what you need to get up and running, and if you need more of the app we'll give it to you as you need it." VS "here's the whole app, parse it and live with it."

Atm, all you can use of Suspense today is the component which displays a fallback until its children have finished loading.

React.lazy - look into...allows a lazy import

const Editing = lazy(() => simulateNetworkSlowness(import('/edit'))); //simulating lazy load

const NoteContent = ({ note, setNotes, isEditing }) => {
  if (isEditing) {
    return (
      <Suspense fallback={<Loading />}>
        <Editing note={note} setNotes={setNotes} />
      </Suspense>
    );
  }
};

^ If we dont need to edit, just browse, then you can put it off. Putting stuff off is a really easy performance win with relatively little downside.

Breaking your app in lil pieces so it doesn't run all at once is a nice performance win in general.

UseTransition

Specifically prioritize batched state updates, making sure low priority state is always updated after high priority state has finished. Ex: react ="Hey I'll start on this once everything's done over there."

This is 100% a 'only if you need it' concept, ex: computationally expensive operations. This is meant to make an app feel responsive to users even if there's a lot going on. ex: "let the user have immediate feedback on typing/clicking, but still kick off the computation processes."

^ This is bc of batching state updates, ex:

const [input, setInput] = useState('');
const [list, setList] = useState([]);

const LIST_SIZE = 2000;

function handleChange(e) {
  setInput(e.target.value); //cheap
  const l = [];
  for (let i = 0; i < LIST_SIZE; i++) {
    //expensive
    l.push(e.target.value);
  }
  setList(l);
}

we need to prioritize the two, making setInput have first priority, still batching state updates, but specifically ranking them:

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition(); //aka 'all the stuff inside startTransition are low priority'

const [input, setInput] = useState('');
const [list, setList] = useState([]);

const LIST_SIZE = 2000;

function handleChange(e) {
  setInput(e.target.value); //cheap
  startTransition(() => {
    const l = [];
    for (let i = 0; i < LIST_SIZE; i++) {
      //expensive
      l.push(e.target.value);
    }
    setList(l);
  });
}
  • React < 17 = "everythings important, do it all now!"
  • React 18 = "some things are very urgent, do them first! Some things can wait! Not everything is created equal."
  • startTransition() - used when triggering an update: "this fn I'm about to run is not urgent", like useCallback()
  • useDeferredValue() - used when receiving new data from a parent component (or an earlier hook from the same component): "if you depend on this value, know that its expensive and you should hold off."

useTransition example:

const [filters, setFilter ] = useFilters(initialFilters);
const [filterInputs, setFilterInputs] = useFilters(initialFilters);

const [isPending, startTransition] = useTransition(); //isPending will automatically be set to false afterward

const visibleTasks = useMemo(
  () => filterTasks(tasks, filters),//the expensive computation
  [tasks, filters]
);

const handleChange = (e) => {
  const {name, value } = e.target;
  setFilterInputs(name, value); //do this first
  startTransition(() => { //then kick this off
    setFilter(name, value);
  });
}

return (
  <main>
    {isPending && <p>Loading!<p>}
    <Filters filters={filterInputs} onChange={handleChange} />
  </main>
);

^ So the expensive thing is still happening, but we get the loader for free...this is to be used in the rare occurrence when you have a really expensive computation that you have to do, but you cant hold up other processes for the user.

useDeferredValue

"once this value has stopped updating, then kick off this expensive process" Similar to debounce(), where it waits a specific time when you're not typing, then makes an API call or something like that. with useDeferredValue(), react waits for all state updates are done, then does something.

export default function List({ input }) {
  const LIST_SIZE = 2000;
  const list = useMemo(() => {
    const l = [];
    for (let i = 0; i < LIST_SIZE; i++) {
      //expensive
      l.push(<div key={i}>{input}</div>);
    }
    return l;
  }, [input]);

  return list;
}

Let's make sure React waits a while until it kicks off the expensive process, aka "once this value has stopped updating, then kick off this expensive process"

export default function List({ input }) {
  const LIST_SIZE = 2000;
  const deferredInput = useDeferredValue(input); //
  const list = useMemo(() => {
    const l = [];
    for (let i = 0; i < LIST_SIZE; i++) {
      //expensive
      l.push(<div key={i}>{deferredInput}</div>);
    }
    return l;
  }, [deferredInput]);

  return list;
}

useTransition(): useCallback() :: useDeferredValue(): useMemo() ^ one is for expensive fns, the other for expensive values.

  • useTransition - "prioritize batch state updates by making this expensive computation go last"
  • useDeferredValue - "wait for this specific expensive value, then kick off the processes that use it."

Conclusion

  1. If you can solve a problem with how you shape your component hierarcy or state -do that first.
  • consider data structure normalization to create the flattest structure you can helps avoid problems down the line
  • component composition is 🔑 (peep the green lines using profiler/devTools)
  • more of art than a science re: reducing re-renders.
  1. Memoization is a solid strategy only if the cost of checking pays for itself with the time you save rendering.
  • memo()
  • useMemo()
  • useCallback()
  1. Using the Suspense API to progressively load your application is a good idea, and more stuff will come soon.

  2. The Transition API is there for you when you're really in a pickle regarding expensive computations that you cannot avoid, but still need to keep your UI performant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment