Making React fast by default and truly reactive

Sep 25, 2022

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:

  1. ⚡️ Much faster
  2. 🦄 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:

  1. 🐢 It's slow by default
  2. 🤯 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 />
  </>)
}
Passing count through props
Renders: 1
Count: 1
Intermediate
Renders: 1
Counter
Renders: 1
Count: 1
Unrelated element
Renders: 1

You might be thinking this is a silly example. It's so easy to optimize:

  1. You'd wrap each component in memo to keep them from re-rendering
  2. But the count prop is still changing so it needs to be in aContext instead of prop drilling
  3. Any click handlers would need to be wrapped in useCallback so they don't break memo
  4. No components can have children because that breaks memo
  5. 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.

Normal React
// 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>
}
Renders: 1
Count: 1
Legend-State
// This component renders once
function LegendStateCount() {
  const count = useObservable(1)

  useInterval(() => {
    count.set(v => v + 1)
  }, 600)

  // Text element re-renders itself
  return <div>Count: {count}</div>
}
Renders: 1
Count:
1

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.

Normal React
// 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>
  )
Renders: 1
Showing child: false
Legend-State
// This component renders once
function LegendStateModal() {
  const showChild = useObservable(false)

  useInterval(() => {
    showChild.set(value => !value)
  }, 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>
  )
}
Renders: 1
Showing child:
false

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.

  1. State changed, so
  2. The component re-rendered, so
  3. 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:

  1. State changed, so
  2. 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.

Normal React
// 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} />
  )
}
Legend-State
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 (
    <Legend.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.

Normal React
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 />
  </>)
}
Passing count through props
Renders: 1
Count: 1
Intermediate
Renders: 1
Counter
Renders: 1
Count: 1
Unrelated element
Renders: 1
Legend-State
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 = useObservable(1)

  useInterval(() => {
    count.set(v => v + 1)
  }, 600)

  return (<>
    <div>Count: {count}</div>
    <TreeLeft count={count} />
    <TreeRight />
  </>)
}
Setting observable
Renders: 1
Count:
0
Intermediate
Renders: 1
Counter
Renders: 1
Count:
0
Unrelated element
Renders: 1

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 of useState and useReducer, components stop re-rendering by default.
  • And then when components only render once, we don't need useMemo or useCallback anymore.
  • Since effects are not tied to rendering, we replace useEffect with useObserve 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.

Normal React
// 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>
  )
}
Renders: 1
Loading...
Legend-State
// 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}</div>
      </Show>
      <For each={messages}>
        {message => (
          <div>{message.text}</div>
        )}
      </For>
      <div>
        <Legend.input value$={currentMessage} />
        <Button onClick={onClickAdd}>Send</Button>
      </div>
    </div>
  )
}
Renders: 1
Loading...

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.

  1. You'd extract the messages list to its own memoized component.
  2. You'd extract each message item to its own memoized component too.
  3. Adding the message does an expensive array clone - maybe there's a way to optimize that?
  4. 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 a useRef to keep it stable?
  5. 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, enableLegendStateReact } from '@legendapp/state/react'
import { useRef } from 'react'
import { useInterval } from './useInterval'

enableLegendStateReact()

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: {count}</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!


React and React Native finally feel the same

May 23, 2022

If you're a developer of both React and React Native apps, it can be tough to switch between platforms because they feel so different. And in many ways React Native feels relatively... well, backwards.

Despite great improvements in DX (Developer Experience) for web development with animation libraries like Framer Motion and much easier styling with Tailwind CSS, React Native is still mired in the madness of creating StyleSheets and managing animation states with Animated or Reanimated.

Yes, I know it's basically all JSX. But jumping from CSS and declarative animations on web to React Native's Animated.spring and StyleSheet.create is just a real big pain, and requires a lot of learning.

This is a big problem for small teams - vastly different platforms means developers need to learn a lot more to do both, or you need to hire more developers on separate web and mobile teams.

But this problem is finally solved ğŸŽ‰

<motion.div
  className="p-4 bg-gray-800 rounded-lg text-white"
  animate={{ x: value * 100 }}
>
  React component
</motion.div>

<Motion.View
  className="p-4 bg-gray-800 rounded-lg"
  animate={{ x: value * 100 }}
>
  <Text className="text-white">
    React Native component
  </Text>
</MotionView>
React component
React Native component
value:
0

Using NativeWind and Legend-Motion we can now write React and React Native code using the same styling and animation patterns, and even mix them together in React Native Web.

In this example you can see an HTML element in React right next to a React Native element in React Native Web, styled and animated in the same way 🤯.

The Problem

React and React Native are similar but different in significant ways, so although React components and React Native components share the same concepts, they need to be written fundamentally differently because:

  1. Styling is different: React uses CSS and React Native uses StyleSheet.
  2. Animations are different: React uses CSS transitions or libraries like Framer Motion while React Native uses the built-in Animated or Reanimated.
  3. Navigation is different: Web and mobile apps are just fundamentally different, so (for now) we're fine with having separate navigation systems and we're focusing on the components themselves. Though, Solito is an interesting project trying to align them that we're watching closely.

The Solution

React developers have recently aligned around using Tailwind CSS for styling and Framer Motion for animations. These both have great DX that feels in many ways easier than the built-in React Native solutions. So if we use libraries for React Native that bring our favorite APIs to React Native, then we can have the same developer nirvana on both platforms.

1. Styling with nativewind

NativeWind is a new library that uses Tailwind CSS as a universal design system for all React Native platforms. It has three features that are crucial for us:

  1. It uses className as a string, just like React components.
  2. It has great performance because it converts className to styles with a Babel plugin so there is almost no runtime cost.
  3. In React Native Web it simply passes className straight through to the DOM components, so it uses normal Tailwind CSS with no overhead.

This means we can have convenient and familiar Tailwind CSS usage on mobile with no overhead, and on React Native Web it just uses Tailwind CSS directly. If you inspect the example below in your browser developer tools you'll see the classNames passed through to the rendered div element.

import { Pressable, Text } from "react-native";

/**
 * A button that changes color when hovered or pressed
 * The text will change font weight when the Pressable is pressed
 */
export function MyFancyButton(props) {
  return (
    <Pressable
        className="p-4 rounded-xl component bg-violet-500 hover:bg-violet-600 active:bg-violet-700"
    >
        <Text
            className="font-bold component-active:font-extrabold"
            {...props}
        />
    </Pressable>
  );
}
Text

2. Animations with Legend-Motion

Legend-Motion is a new library (that we built) to bring the API of Framer Motion to React Native, with no dependencies by using the built-in Animated. This lets us create animations declaratively with an animate prop, and the animation will update automatically whenever the value in the prop changes.

Try hovering over and clicking the box to see it spring around.

<Motion.View
    initial={{ y: -50 }}
    animate={{ x: value * 100, y: 0 }}
    whileHover={{ scale: 1.2 }}
    whileTap={{ y: 20 }}
    transition={{ type: "spring" }}
/>
value:
0

3. Mix React and React Native Web

React Native Web supports mixing HTML and React Native elements together, so it's easy to incrementally drop React Native Web components into a React app. That's a huge boon because we can drop our React Native components into our web apps without needing to write the whole thing with React Native Web.

<motion.div
    className="p-5 text-xs text-black bg-blue-200 rounded-lg"
    whileHover={{ scale: 1.1 }}
    transition={{ type: 'spring' }}
>
    <div>DIV element</div>
    <Motion.View
        className="p-5 mt-6 bg-blue-400 rounded-lg"
        whileHover={{ scale: 1.1 }}
        transition={{ type: 'spring' }}
    >
        <Motion.Text className="text-black">
            React Native Element
        </Motion.Text>
        <motion.div
            className="p-5 mt-6 bg-blue-600 rounded-lg"
            whileHover={{ scale: 1.1 }}
            transition={{ type: 'spring' }}
        >
            DIV text
        </motion.div>
    </Motion.View>
</motion.div>
DIV element
React Native Element
DIV text

Putting it all together

Using NativeWind and Legend-Motion together we can build complex React Native components in an easy declarative way that will look very familiar to React developers:

<Motion.View
  className="p-4 font-bold bg-gray-800 rounded-lg"
  animate={{ x: value * 50 }}
>
  <Text>
    Animating View
  </Text>
</Motion.View>
<Motion.View
  className="p-4 font-bold bg-gray-800 rounded-lg"
  whileHover={{ scale: 1.1 }}
  whileTap={{ x: 30 }}
>
  <Text>
    Press me
  </Text>
</Motion.View>
Animating View
Press me
value:
0

Try it now

1. Legend-Motion

Legend-Motion has no dependencies so it's easy to install.

npm
yarn
npm i @legendapp/motion

Then using it is easy:

import { Motion } from "@legendapp/motion"

<Motion.View
    animate={{
        x: value * 100,
        opacity: value ? 1 : 0.5,
        scale: value ? 1 : 0.7
    }}
>
    <Text>Animating View</Text>
</Motion.View>
Animating View
value:
0

See the docs for more details and advanced usage.

2. nativewind

NativeWind has some tailwindcss configuration and a babel plugin so see its docs to get started.

3. React Native Web

React Native Web support for this is right on the bleeding edge. The pre-release version of React Native Web 0.18 adds the style extraction features that NativeWind depends on. But there's an issue in its implementation of Animated that breaks all other styles when using using extracted styles. I have a fork of the pre-release 0.18 version that fixes this, so if you want to try it now, install react-native-web from my fork:

npm
yarn
npm i https://gitpkg.now.sh/jmeistrich/react-native-web/packages/react-native-web?0.18-animated

It's a pre-release version so of course be careful when using it in production, but we're using it for the examples on this site and another app in development and haven't found any issues. Hopefully RNW 0.18 will release soon with Animated working well, and we can stop using my fork ğŸ¤ž.

Towards Developer Utopia 🌟☀️✨🌈

To get to the utopic future of one platform that runs everywhere there's basically three paths:

  1. All web: This has long been the only viable solution, but mobile web apps can be slow and clunky if not done right. It is possible to build great mobile web apps, and we'll have a future blog post on that, but it's hard.
  2. All React Native: React Native Web is getting there! But it still needs to progress further, and we hope it does! See The Case for the React Native Web Singularity for a deeper dive. For now, it has a performance overhead compared to normal web apps and doesn't support all web features yet.

The problem with both of those solutions is you have to go all in on one platform and accept the limitations. We prefer what I like to call:

  1. A Pleasant Mix 🥰: We use React Native for mobile apps where it shines. We use React for web apps where it shines, and we drop in React Native Web components when we want to share components. For example, we have an admin dashboard in React with a Preview button that embeds the actual React Native components users see in the mobile apps.

Now that we finally can use the same styling and animation patterns, it's much easier for one team to work on both React and React Native. Until recently we had separate web and mobile teams, but in just the past few weeks that we've been using NativeWind and Legend-Motion, we've already merged everyone into one team that can do anything 🚀.

Stay tuned

We are very excited about this new world of React and React Native working beautifully together! And we hope you are too ğŸŽ‰ğŸ¥³.

A huge shoutout to NativeWind for being so great. Give it a star on Github and follow Mark Lawlor for updates.

Legend-Motion is our first open source library and we plan to keep improving it. We're also working on pulling out more of our core code into open source projects, so keep an eye on this blog and follow us or me on Twitter for updates: @LegendAppHQ or @jmeistrich.