React Tricks: Fast, Fit and Fun

Lessons and hacks learned from developing a micro-library
This is a transcript of my talk that I gave at the Copenhagen React Meetup in November 2023. I tried to summarize some tricks and hacks that I've learned while maintaining an open-source React library — a small and fast React router. This library has gained some interest over the last couple of years, primarily among developers who care about performance and an extremely small bundle size.
I must admit, when I start working on a new project, performance and size are not my top priorities. My focus is on shipping fast and delivering value to the users. To be honest, most users probably wouldn't notice if their app takes an extra 50ms to boot up. However, as the project grows, the accumulated poor performance becomes a noticeable obstacle in user experience. As a library author, I feel like it's my responsibility to ship optimized code so that developers don't experience degraded performance in their apps because of some third-party dependency.

Wouter: a micro router

Started in 2019, this project was intended to teach how to use React hooks to migrate away from heavy class components. I drew inspiration from React Router, but only kept the basic building blocks and tried to simplify the API. Fast forward a couple of years, it turns out there are developers who find React Router too complex and heavy for their apps. Wouter now has over 5.5k stars on Github, 150k downloads per month, and a very loyal community.
Over the past few months, @jeetiss and I have been working on the upcoming major release, v3. However, as the project gains popularity, it starts to deviate from the initial concept. So now, the library is no longer 1.5Kb… it’s 2.1Kb. But it’s packed with tons of new features: nested routing, wildcard patterns, search params, better SSR support, hash location, memory location, history state, useParams and more.
notion image
Maintaining this project is extremely challenging and fun, in order to keep the bundle size small and reduce dependencies. We use size-limit integrated into the CI to calculate and report the bundle size between builds. A small bundle size means faster app loading times, better LCP, and FID. However, a smaller size of a text file doesn't always result in smaller bundles because the content is most likely compressed with gzip or brotli. Here is a really interesting example: nanoid uses a re-ordered alphabet to achieve better Brotli compression.
This is some hardcore code-golf magic to save up 17B, courtesy of @subzey
This is some hardcore code-golf magic to save up 17B, courtesy of @subzey
At the other hand, faster app means better INP, the web vitals metric that will eventually replace FID. There are many ways to improve performance, but in React the most essential one it to minimize the number of VDOM segments being re-rendered.
Wouter's source code does not look pretty, but that's mostly to keep the bundle size small, while also ensuring that there are as few re-renders as possible. The band-aid for achieving the best rendering performance in React is to ensure that object references are stable, i.e. they change as few times as possible. We will get back to this topic later on, but first let’s look at some tricks that I’ve learned while implementing these optimizations.

Component composition with React.cloneElement

Let’s start with an easy one. This isn’t actually a hack, but rather a standard React API. Although, React docs highly discourage from using it, it can be helpful when you want to achieve composability and extension of components that you don’t have control over.
This method simply allows you to add new props and children to an existing React element, or let’s put it simply a JSX. It works with primitive elements:
cloneElement(<img />, { src: "keyboard-cat.webp" }) 
// <img src="keyboard-cat.webp" />
With React components:
cloneElement(<Alert critical />, { color: undefined }) 
// <Alert />
Or even fragments (though, not sure why you would need that…):
cloneElement(<></>, { why: "?" }) 
// <React.Fragment why="?" />
But, it can’t accept strings, numbers, arrays etc. since they are not valid elements. A common pattern is to apply cloneElement to the component’s children right after checking that it is indeed a valid element with isValidElement. The Link component in wouter uses so-called asChild pattern” to make it possible to customise an underlying anchor element. You can also find this pattern in other libraries or frameworks, such as Next.js or Radix UI.
// <a href="/">Hi!</a>
<Link to="/">Hi!</Link> 

// ⛔️ <a href="/"><a /></a>
<Link to="/"><StyledLink /></Link>

// 👍 <a href="/" />
<Link to="/" asChild><StyledLink /></Link>
With this trick in place, we can now use links with alternative element types or even your own React components and avoid double wrapping. Here is what the simplified Link implementation looks like:
const Link = ({ asChild, children, href }) => {
  if (asChild && isValidElement(children)) {
    return React.cloneElement(children, { href })
  }
	
	return <a href={href}>{children}</a>
}
But it doesn't stop there! cloneElement can also override refs, which opens up other interesting use cases. Let's say you want to add some interactivity to the component, but you don't want to wrap it in an extra div, or you can't rewrite this component to support proper composition.
Since we are talking a lot about performance today, let’s implement <RenderIsExpensive /> helper component that will highlight every re-render of that component with some completely unnecessary visual feedback. The idea is to get the reference to a DOM element by overriding a ref prop and then attach some floating dollar symbols. Don’t worry about the animation itself, I fed a very detailed request to ChatGPT and was able to nail the result in a couple of iterations.
const RenderIsExpensive = ({ children }) => {
  const targetRef = useRef(null)

	const emitParticles = () => {
    // some magic over `targetRef.current` 
 	}

	useEffect(() => {
	  emitParticles()
	}) // no deps, fires after every render

  return cloneElement(children, { ref: targetRef })
}

// usage:
<RenderIsExpensive>
  <Button>Counter: {counter}</Button>
</RenderIsExpensive> 
You can see the component in action in this demo below. We all love counters that increment, so let's wrap the counter component in <RenderIsExpensive />.
We're getting it ready...
Of course, this is a very naive implementation that doesn’t cover cases like context updates, but you can see from this example how adding props to existing React elements can help you write utility components that add interactivity to other components without wrapping them in extra divs. To make React highlight updates in the DOM, you can use “Highlight updates when components render” setting in the React Profiler. See also Ivan’s article on how to profile React components.
notion image
Avoid excessive use of closeElement. If you need to encapsulate business logic or extract building blocks of interactivity, it is preferable to use hooks or the function-as-a-prop pattern. Before moving on to the next trick, let's briefly review the basics of performance in React.

React Performance 101

Performance in React is a wide-ranging topic. I will not have time to cover everything, but here is a TLDR;
  1. Components in React are not pure by default
  1. To make them pure, wrap the component in React.memo
  1. Be mindful of non-primitive values, avoid unnecessary reference changes.
Components in React are not pure by default, meaning that even if props haven’t changed, the component will still re-render. We can fix that by wrapping our component in React.memo, not to be confused with useMemo. There is a nice intro to rendering and memoization in React by Josh Comeau. Additionally, React team is actively working on React Forget — the compile time optimiser that wraps pure components in memo for you.
We're getting it ready...
Take a look at the example above. I asked ChatGPT to generate a list of randomly funny cat names and added the real names of my cats to that list. The label component accepts a boolean prop called isThisMyCat and renders a message accordingly. Notice how in the first example, the label is always re-rendered even when the prop stays the same. In the second example, the label is wrapped in React.memo, so it only updates when necessary.
notion image
But you should still be careful about keeping references to objects and functions stable. React.memo compares previous and current prop values using Object.is by default, and while this works fine for primitive values, there is a risk that you might accidentally pass an anonymous function or a non-constant array as a prop. One option is to provide a custom comparison callback, for example, to check deep object equality, or we can minimize the number of times the object reference is changed.
const Fast = React.memo(SlowComponent) 

// Whoops, we messed up 
<Fast onDone={() => { ... }} />
<Fast severity={["warning", "error"]} />
Is this what we will call “stable object references”. Here is how you can stabilize them:
  • useMemo: construct “computed” values based on the list of dependencies. It is not guaranteed to be recomputed though, see React docs.
  • Move static functions, objects, array to global namespace.
  • useRef, useState and useEvent. See below.

useState that never updates: running component initialisers

You are probably familiar with useState. This hook tells React that there's a value shared between component invocations, and React must re-render when this value changes. However, useState can also be useful even if you completely ignore the setter function.
const [value] = useState(() => { /* initializer */ })
In this code, the initializer will only run once, and the reference to value will never change during the component's lifetime. We can utilize this knowledge to execute code during the component's initialization, prior to any rendering taking place (unlike useEffect or useLayoutEffect).
Suppose we’re writing a hook that changes the document title. We want it to replace the title with the value provided, additionally it should restore the previous value once the component is destroyed.
const useWindowTitle = (title) => {
   const [prevTitle] = useState(() => document.title)

   useEffect(() => {
			document.title = title
			return () => document.title = prevTitle

      // prev title isn't actually needed here, but linter will complain
      // wouter actually supresses these warnings to reduce checks and 
      // keep the size small
   }, [title, prevTitle]) 
}
It can also be used to allocate a heavy resource only once per app's startup and provide it to underlying components.
For example, let's consider a scenario where we are working on adding multiplayer support to an application and need to establish a connection to the server. In this case, we can use the Multiplayer class. When initialised, it connects to the server and enters the room.
We're getting it ready...
In the top-level App component, we call a hook to get the current multiplayer instance and then provide it to children components in a context. By the way, the rules of rendering we just discussed also apply to context — useContext will always re-render the component when the context value changes.
// Method (A)
// inits on app startup, not exactly what we want
let client = new Multiplayer()
const useMultiplayer = () => client

// Method (B)
// better, lazy initialization
let client

const useMultiplayer = () => 
  (client ||= new Multiplayer())

// Method (C)
// By the way, if, for some reason, you prefer not to declare 
// a global variable, here is a more exotic way: using a context 
// with a default empty object that can act as a cache.

const CacheCtx = createContext({})

const useMultiplayer = () =>
  (useContext(CacheCtx).v ||= useMultiplayer())

// Let's use the hook in your app
const App = () => {
  const client = useMultiplayer()
  return <Provider value={client} children={...} />
}
Storing the instance in a global variable works fine until we decide to have multiple multiplayer components on the screen. This is actually not a rare use-case, especially in a micro-frontend app. Our hook above does not properly isolate the instance, so we end up with two windows sharing the same multiplayer client.
We're getting it ready...
To resolve the issue, let's create an instance scoped to the component that calls the hook. We cannot use useMemo because it is not guaranteed to be called only once. This is where useState becomes useful - fortunately, we can rely on the initializer function.
const useMultiplayer = () => {
  const [client] = useState(() => new Multiplayer())
  return client
}
You can read more about this trick in this article. To recap, use useState in a top-level component with an initializer and no setter function to run heavy initialization logic only once per application startup. The demo below illustrates two multiplayer canvases, where each instance is properly initialized.
We're getting it ready...
Update. After making some experiments, I discovered that in Strict mode, React will call your initializer function twice in order to "help you find accidental impurities". While this behavior only happens during development and does not affect production, it can be potentially dangerous. To fix this, we can use useRef to properly cache the initialized value. However, this is not critical when the resource has no side-effects, as it works perfectly fine in wouter.
I should mention that in most production apps you can and should use useRef to cache values, but I personally love this trick because it compresses better. If this isn’t the only place where you call react hooks, the compiler will use one-letter aliases, but unfortunately property name current can’t be dropped from the code.
notion image
Again, this doesn't mean the final Brotli-compressed bundle will be smaller, but using this trick always makes me feel a bit cooler.

Stable callbacks with useEvent

Let's imagine that we want to add a feature to allow users to leave comments in our multiplayer app. We are writing a callback function called leaveComment that will attach a comment object to the canvas at the current mouse position. To effectively pass this method to a component, we are wrapping it in useCallback and adding x and y to the list of dependencies.
const [x, y] = useMousePosition(canvasRef)

const leaveComment = useCallback((text) => {
  ...
}, [x, y])
But here is a problem: because x and y change too often, useCallback isn't effective. In fact, it creates overhead and can actually hurt performance.
useEvent uses the latest callback provided but returns a stable reference
useEvent uses the latest callback provided but returns a stable reference
useEvent is a hook that returns a stable reference to the callback, essentially using the latest function provided as an argument. It returns a stable wrapper function that internally always calls the most recent callback provided to the hook. The fate of this feature is still unclear, as it currently exists only as a React RFC, and there is no guarantee that it will be implemented in React at all. However, there are user-land implementations available on NPM.
Let’s fix our code by wrapping leaveComment in a useEvent
import useEvent from "react-use-event-hook" // user-land shim

const [x, y] = useMousePosition(canvasRef)
                     
const leaveComment = useEvent((text) => {
  ...
}) // no deps!
In this example, leaveComment always holds the same value, so no extra re-rendering happens. Note the empty list of dependencies, this is the primary difference between useCallback and useEvent. Compare the two examples below: the second one will only render the Comment component once.
We're getting it ready...
Sometimes when the list of dependencies grows, you are more likely to get unnecessary re-renders. There are also cases, when one of the dependencies changes regularly, for example in this useDebounce implementation where there the callback is provided by the user. Though, you should always carefully estimate the trade-off between using useCallback and useEvent.

Using useSyncExternalStore to safely subscribe to external state changes

When you're developing a React app, chances are you are using a library that subscribes to some external state updates. When I say external, I mean things that happen outside of DOM event handlers like clicks or onChange events. State management libraries, WebSockets, navigator.onLine handlers, all these things are usually implemented similarly in React: using a combination of useEffect and useState.
notion image
Let’s say in our multiplayer app, we want to update the name of the current user/player if they decide to change it. Since we’re talking about a multiplayer environment, it should also work over WebSockets. Our multiplayer client has the following API:
multiplayer.name // => "@molefrog"

const unsub = multiplayer.on("rename", () => ...) 
unsub()
Let’s write a hook that returns this name and redraws when it is changed:
const useUserName = () => {
  const [name, setName] = useState(client.user)

  useEffect(() => {
    return client.on("rename", (newName) => setName(newName))
  }, [multiplayer])
}
This worked fine, until… React introduced concurrent rendering. TLDR; Because React now can render concurrently, it means that it is possible different parts of the UI will be inconsistent with the global state. This is called tearing. If you’re using useState / useContext you will be fine, however when you rely on external state you might end up with unwanted glitches. Tearing is an edge case that can create bugs that are difficult to spot. To better understand this concept, take a look at Colin Campbell's informative tearing demo.
The second problem is that this code won’t work properly in SSR. For example, Next.js will complain about using useEffect during server-rendering. Both of these issues can be resolved with useSyncExternalStore introduced in React 18.
notion image
The third argument is optional, and it is a function for fetching the value from the server. In our example, multiplayer can only work on the client-side, so we can return a placeholder in that case. Try clicking on the current player's name in the top right corner. Notice how the name is updated everywhere accordingly.
We're getting it ready...
Be careful when using anonymous functions to subscribe to the store. uSES tracks reference changes and re-subscribes on every render. In most cases, such as when subscribing to location updates or window resize events, you can fix this by moving the subscription function outside of the component. There are situations when you need to provide custom arguments like a user ID, and that's when the stable function reference tricks can be helpful: useState, useEvent (when arguments don't change over time), and useCallback in other cases. So, useSES can recalculate the value appropriately.
const [subscribeToRename] = useState(
  () => cb => client.on("rename", cb)
)

useSyncExternalStore(subscribeToRename, () => client.name)
With useSES we can easily implement a selector hook for easily accessing individual fields of a complex data structure in the store. useState is enough here since we are assuming that selector prop never changes.
As a bonus feature, useSES results in smaller code. The same 78 B function can be compressed down to 28 B.
// Nice!
let fn=c=>e(t,(()=>c.name)) // 28B
let fn=c=>{let[r,m]=a((()=>c.name));return n((()=>t((()=>m(c.name)))),[c]),r} // 78B
To recap, use the useSyncExternalStore hook to subscribe to external state in an SSR and concurrent mode-friendly way. It is smaller, easier to implement and more performant. This is exactly how useBrowserLocation, useHashLocation, useSearch hooks are implemented in wouter.

Advanced: On syncExternalStore and hashchange event

Ok, this one is a bit mind-blowing. We discovered this when we were working on the useHashLocation hook. This probably still needs further investigation, but the fact is that it actually fixed a non-obvious bug. Components that called this hook were experiencing tearing. Here's a quick summary, but if you have a better explanation for why this happens, please let me know!
  • There are sync and async browser events. Sync events are triggered with dispatchEvent. hashchange in WebKit is an async event (however popstate isn’t, which is non-standard behaviour and was probably done for compatibility purposes, see this comment in WebKit’s source).
  • React probably schedules a micro-task for rendering when callback is called. This causes micro-tasks execute in between event listener invocations.
  • If this event was synchronous, then updates would have been batched until all listeners were invoked.
  • Solution → subscribe to hashchange once and then fire a sync event, so that all uSES callbacks are invoked within the same task.

Closing thoughts

In this article, we explored some basic tricks to optimize performance in React apps. While these tricks may seem excessive for real-world applications, they proved to be effective in boosting performance and keeping the size of my micro-library small. Plus, it was a lot of fun!
 
If you enjoyed this article, you can show your support by giving wouter a star on GitHub or subscribing to @mlfrg. You can go even further by adding it to your project and providing feedback as a developer.
If you enjoyed this article, you can show your support by giving wouter a star on GitHub or subscribing to @mlfrg. You can go even further by adding it to your project and providing feedback as a developer.