Making React fast by default and truly reactive
We love React and we've been very happily using it since 2015, but the dev experience and performance has always been held back by one fundamental flaw. We think we've found a way to solve it, to make React both:
- ⚡️ Much faster
- 🦄 Much easier to use
For all of its benefits and wonderful ecosystem, developing with React can feel needlessly complex, and React apps can be slow if you don't optimize correctly. We've found two main issues that can be much improved:
- 🐢 It's slow by default
- 🤯 Hooks are too complex
Of course React can be very fast, but achieving good performance requires wrapping components in memo
, carefully ensuring props to children don't change with useCallback
or useMemo
, and managing dependency arrays. Hooks were a big step up from classes but they still create a lot of complexity. Making sure you're not using stale data, that variables are persistent across renders, and that dependency arrays do what you expect are a big nuisance that cause terrible bugs if you get it wrong.
These performance issues and confusing/frustrating experience all boil down to one fundamental problem:
React renders too much, too often.
By default, React always re-renders everything, all the way down. So React apps have a huge performance problem by default, because any little change re-renders everything.
function TreeLeaf({ count }) {
return <div>Count: {count}</div>
}
function TreeLeft({ count }) {
return <TreeLeaf count={count} />
}
function TreeRight() {
return <div>Unrelated element</div>
}
function Tree() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v + 1)
}, 600)
return (<>
<div>Count: {count}</div>
<TreeLeft count={count} />
<TreeRight />
</>)
}
You might be thinking this is a silly example. It's so easy to optimize:
- You'd wrap each component in
memo
to keep them from re-rendering - But the
count
prop is still changing so it needs to be in aContext
instead of prop drilling - Any click handlers would need to be wrapped in
useCallback
so they don't breakmemo
- No components can have children because that breaks
memo
- Adding more state to the
Context
makes more components re-render on every change, so maybe you should use global state instead?
But that's the point. The performance problem is fixable, but the fixes are what cause the complexity. Because a functional component is just a function, all local variables are ephemeral by default. The workaround is Hooks, to keep local state and functions stable between renders. So the constant re-rendering causes two problems:
1. Unoptimized by default
The responsibility is on us developers to make it performant, to use memo
to prevent children from re-rendering, and to make sure props don't change so that memo
actually works. Fun fact: the children
prop is always different, so memo
is useless for components with children.
2. Need hooks to keep state/functions stable
The built-in hooks like useCallback
, useMemo
, useRef
, useState
, etc... exist for the sole purpose of keeping data consistent between renders. Dependency arrays make it your responsibility to tell React on which renders effects should run. Prop-drilling can obviate the benefit of memo
so there's another workaround, Context, which adds another layer (or many nested layers) of complexity.
An enormous amount of the React experience is in compensating for the fact that components are constantly re-rendering, and those complex workarounds are the source of much of the mental overhead 🤯.
We've seen a lot of really cool new frameworks with different approaches to the rendering model solving some of these problems, like Solid and Svelte. But we really love the React ecosystem and React Native, so we tried to solve it within React.
Changing React to render only once
It doesn't make a lot of sense that changing one piece of text should have to re-render an entire component, or that showing an error message should re-render the whole component, or that editing an input should re-render its parent component on every keystroke.
We found that we could improve this without even changing React itself, but with just a state library.
So we built Legend-State to rethink the way we write React code. Rather than constantly re-rendering everything and working around it, components own the state they care about and re-render themselves when needed. Taking that even farther, the individual DOM nodes (or React Native Views) at the smallest and lowest level manage their own updating, so components never re-render.
// This component renders every time
function HooksCount() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v + 1)
}, 600)
// Count changes every render
return <div>Count: {count}</div>
}
// This component renders once
function LegendStateCount() {
const count$ = useObservable(1)
useInterval(() => {
count$.set(v => v + 1)
}, 600)
// Memo element re-renders itself
return <div>Count: <Memo>{count$}</Memo></div>
}
We can pass an observable
straight into the JSX, and it automatically gets extracted to a tiny memoized component that tracks its own state. So it updates itself when it changes, and no other component needs to care about it.
The whole component doesn't need to re-render every time, only the one single text node.
So this is great! Text can re-render itself! But apps are obviously about a lot more than text. How do we handle more complex things like dynamic styles or conditional rendering?
🔥 Fine-grained reactivity
The conventional approach to optimizing React is to extract subtrees of JSX into separate components, wrap them in memo
, and pass down all the props they need. That's much easier to do with observable
than with useState
because observables are stable references that won't trigger memoized components to render.
But still, it's just so cumbersome and time-consuming to do that everywhere, and it often isn't even possible.
Legend-State solves this with a Computed
component, which isolates usage tracking away from the parent. Anything inside a Computed
runs in its own state context, so the parent component never has to update.
Computed is a broad brush so we also have some more purposeful components like Show
, Switch
, For
, and two-way binding with reactive props. These can be combined to optimize renders to only the tiniest elements that truly need updating.
// This component renders every time
function HooksModal() {
const [showChild, setShowChild] = useState(false)
useInterval(() => {
setShowChild(value => !value)
}, 1000)
return (
<div>
<span>Showing child: </span>
<!-- Conditional className and text -->
<span
className={showChild ? 'text-blue' : ''}
>
{showChild ? 'true' : 'false'}
</span>
<!-- Conditional render -->
{showChild && (
<div>Child element</div>
)}
</div>
)
// This component renders once
function LegendStateModal() {
const showChild = useObservable(false)
useInterval(() => {
showChild.toggle()
}, 1000)
return (
<div>
<span>Showing child: </span>
<!-- Runs in a separate tracking context -->
<Computed>
<span
className={showChild.get() ? 'text-blue' : ''}
>
{showChild.get() ? 'true' : 'false'}
</span>
</Computed>
<!-- Conditional runs in separate context -->
<Show if={showChild}>
<div>Child element</div>
</Show>
</div>
)
}
Components using this fine-grained rendering get a massive performance improvement by rendering less, less often. No matter how fast a Virtual DOM is, it's always faster to do less work.
But this optimization is only half of the goal here. Changing rendering to be optimized by default lets us remove a lot of the complexity and think about React in a simpler way.
🦄 An easier mental model
The mental model of React revolves around the render lifecycle. Hooks run on every render, so dependency arrays are needed to manage it. On the next render, old functions and variables become stale so we need useCallback
and useState
to keep them consistent between renders. Code running inside functions may be accessing stale variables, so we make them persistent with useRef
. We've gotten used to it, but it's legitimately very complicated.
After tons of experimenting, we found that a more straightforward mental model is to observe state changing, not renders.
Cause => Effect
The mental model in React is not a clear cause => effect relationship. It's three steps: cause => effect => side effect.
- State changed, so
- The component re-rendered, so
- Run your code
But that second step is what causes all the madness 😱, so we just skip it. Without depending on the re-rendering step we have a simple cause => effect relationship:
- State changed, so
- Run your code
When actions aren't dependant on re-rendering, we don't need useEffect
or dependency arrays anymore. Instead we have useObserve
, which automatically runs whenever the state it observes changes. All you need to do is access state within it, and it tracks dependencies automatically.
// This component re-renders on every keystroke
function HooksName() {
const [name, setName] = useState('')
// Trigger on render if name has changed
useEffect(() => {
document.title = `Hello ${name}`
}, [name])
// Handle change and update state
const onInputChange = (e) => {
setName(e.target.value)
}
// Controlled input needs both value and change handler
return (
<input value={name} onChange={onInputChange} />
)
}
const profile = observable({ name: '' })
// This component renders once
function LegendStateName() {
// Trigger when name changes
useObserve(() => {
document.title = `Hello ${profile.name.get()}`
})
// Two-way bind input to observable
return (
<Reactive.input $value={profile.name} />
)
}
We find this much easier to reason about. There are no dependency arrays or extra hooks to keep things stable between renders. All that matters is when state changes, you do something. Cause => Effect.
Optimized by default
Combining all of the techniques we've discussed so far, React development becomes optimized by default because instead of defaulting to re-rendering the whole world, Legend-State re-renders only what really needs it. Because of that, all of the normal complexity around optimizing renders just disappears.
Revisiting the example from the beginning, the Legend-State version renders each component only once, because only the text of the count needs to change. You'll notice they're exactly the same except for useObservable
instead of useState
. We don't have to do anything crazy here. We don't even have to memo
every component to optimize because they only render one time. It's just optimized by default without thinking about it.
function TreeLeaf({ count }) {
return <div>Count: {count}</div>
}
function TreeLeft({ count }) {
return <TreeLeaf count={count} />
}
function TreeRight() {
return <div>Unrelated element</div>
}
function Tree() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v + 1)
}, 600)
return (<>
<div>Count: {count}</div>
<TreeLeft count={count} />
<TreeRight />
</>)
}
function TreeLeaf({ $count }) {
return <div>Count: <Memo>{$count}</Memo></div>
}
function TreeLeft({ $count }) {
return <TreeLeaf $count={$count} />
}
function TreeRight() {
return <div>Unrelated element</div>
}
function Tree() {
const count = useObservable(1)
useInterval(() => {
count.set(v => v + 1)
}, 600)
return (<>
<div>Count: <Memo>{count}</Memo></div>
<TreeLeft $count={count} />
<TreeRight />
</>)
}
This is of course much faster, but perhaps more importantly it lets us focus on making our apps and spend less time optimizing and figuring out dependency arrays and dealing with stale variables and making sure props don't change.
Even without all these fine-grained rendering improvements, Legend-State is faster than the other major React state libraries. In addition to minimizing renders, it's extremely optimized to be as fast as possible.
Replacing global state AND all of the React hooks
At its core Legend-State is a really fast and easy to use state library, so using it for global state is a great place to start.
But where it gets really powerful is as a replacement for the built-in hooks.
- When we use
useObservable
instead ofuseState
anduseReducer
, components stop re-rendering by default. - And then when components only render once, we don't need
useMemo
oruseCallback
anymore. - Since effects are not tied to rendering, we replace
useEffect
withuseObserve
to take actions when state changes. - For output that transforms other state,
useComputed
creates a computed observable that updates itself when dependencies change. - For data that comes in asynchronously, we can just give the Promise straight to
useObservable
and it will update itself when the Promise resolves.
In this more complex example of a basic chatroom, the Legend-State version creates self-updating computations and observers based on state, so it never has to re-render the whole component.
// This component renders every keystroke
function HooksChat() {
// Profile
const [profile, setProfile] = useState()
// Fetch and set profile
useEffect(() => {
fetch(url).then(response => response.json())
.then(data => setProfile(data))
}, [])
// Username
const userName = profile ?
`${profile.first} ${profile.last}` :
''
// Chat state
const [messages, setMessages] = useState([])
const [currentMessage, setCurrentMessage] = useState('')
// Update title
useEffect(() => {
document.title = `${userName} - ${messages.length}`
}, [userName, messages.length])
// Button click
const onClickAdd = () => {
setMessages([...messages, {
id: generateID(),
text: currentMessage,
}])
setCurrentMessage('')
}
// Text change
const onChangeText = (e) => {
setCurrentMessage(e.target.value)
}
return (
<div>
{userName ? (
<div>Chatting with {userName}</div>
) : (
<div>Loading...</div>
)}
<div>
{messages.map(message => (
<div key={message.id}>{message.text}</div>
))}
</div>
<div>
<input
value={currentMessage}
onChange={onChangeText}
/>
<Button onClick={onClickAdd}>Send</Button>
</div>
</div>
)
}
// This component renders once
function LegendStateChat() {
// Create profile from fetch promise
const profile = useObservable(() =>
fetch(url).then(response => response.json())
)
// Username
const userName = useComputed(() => {
const p = profile.get()
return p ? `${p.first} ${p.last}` : ''
})
// Chat state
const { messages, currentMessage } =
useObservable({ messages: [], currentMessage: '' })
// Update title
useObserve(() =>
document.title =
`${userName.get()} - ${messages.length}`
)
// Button click
const onClickAdd = () => {
messages.push({
id: generateID(),
text: currentMessage.get()
})
currentMessage.set('')
}
return (
<div>
<Show if={userName} else={<div>Loading...</div>}>
<div>Chatting with {userName.get()}</div>
</Show>
<For each={messages}>
{message => (
<div>{message.text.get()}</div>
)}
</For>
<div>
<Reactive.input $value={currentMessage} />
<Button onClick={onClickAdd}>Send</Button>
</div>
</div>
)
}
As you type into the input in the normal version it re-renders the whole component and every child element on every keystroke, including every single item in the list.
Of course this is a silly example and you're probably already thinking of all the ways you'd optimize it.
- You'd extract the messages list to its own memoized component.
- You'd extract each message item to its own memoized component too.
- Adding the message does an expensive array clone - maybe there's a way to optimize that?
- You don't want the Button component to re-render every time so you wrap the click handler in
useCallback
. But then the state is stale in the callback, so maybe you save the state in auseRef
to keep it stable? - You could change the input to be uncontrolled with a
useRef
that you check for the current value on button click, so it doesn't re-render on every keystroke. But then what's the right way to implement a Clear button?
But that's the point. React is unoptimized by default, so you have to do a bunch of complex extra work to optimize it.
In the Legend-State version where we put the concept of re-rendering and all the hooks it requires behind us, React becomes optimized by default and easier to understand. It even takes less code than the most naive unoptimized React implementation.
What next?
Check out the documentation or the GitHub repo to get started with Legend-State. We would love to hear from you on GitHub, or talk to me directly on Twitter.
To get these benefits you don't need to immediately restructure your whole app or anything, and it's still just React so you don't need to change to a whole new framework. We've been migrating our apps gradually, just reducing re-renders in the slowest components and building new components with a one-render design. So you can just experiment with a single component and see how it goes. Or you could even try it right here in this sandbox:
import { useObservable, Memo } from '@legendapp/state/react' import { useRef } from 'react' import { useInterval } from './useInterval' export default function Counter() { const renderCount = ++useRef(0).current; const count$ = useObservable(1) useInterval(() => { count$.set(v => v + 1) }, 600) return ( <div> <div>Renders: {renderCount}</div> <div>Count: <Memo>{count$}</Memo></div> </div> ) }
I think there's a lot of room for rethinking the way we use React to be faster and easier. This is our first attempt at it, but I hope the community will come up with lots of wild and crazy new solutions!