React Tricks: Fast, Fit and Fun
Lessons and hacks learned from developing a micro-library
November 30, 2024
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.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.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 />
.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.
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;
- Components in React are not pure by default
- To make them pure, wrap the component in
React.memo
- 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. 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.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
anduseEvent
. 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. 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.
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.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 useuseRef
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.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
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.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
.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.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.
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 (howeverpopstate
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 alluSES
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!