Skip to content

Why

Legend-State is an evolution of the state system we’ve been using internally in Legend since 2015 and in Bravely since 2020. It needs to be extremely fast because Legend users have documents with hundreds of thousands of items. We recently rewrote it with modern browser features, optimizing for both developer experience and best possible performance / memory usage. Comparing to other state libraries, we think you’ll prefer Legend-State for these reasons:

⚡️ Tiny and FAST

Legend-State is the fastest React state library, designed to be as efficient as possible. It does very little extra work and minimizes renders by only re-rendering components when their observables change. And at only 4kb it won’t hurt your bundle size.

😌 Feels natural

Working with observables is as simple as get() and set() - they work as you’d expect, and the observable functions are right there on the prototype.

const state$ = observable({ value: 1 });
state$.value.get();
state$.value.set(2);

// Tracks automatically and runs on every change
observe(() => {
  console.log(state$.value.get());
});

🔥 Fine-grained reactivity

Using features like Memo it’s easy to isolate renders to the smallest possible change.

import { observable } from "@legendapp/state"
import { Memo, useObservable } from "@legendapp/state/react"
import { useRef, useState } from "react"
import { useInterval } from "usehooks-ts"

function NormalComponent() {
  const [count, setCount] = useState(1)
  const renderCount = useRef(1).current++

  useInterval(() => {
    setCount((v) => v + 1)
  }, 600)

  // This re-renders when count changes
  return (
    <FlashingDiv>
      <h5>Normal</h5>
      <div>Renders: {renderCount}</div>
      <div>Count: {count}</div>
    </FlashingDiv>
  )
}
function FineGrained() {
  const count$ = useObservable(1)
  const renderCount = useRef(1).current++

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

  // The text updates itself so the component doesn't re-render
  return (
    <FlashingDiv>
      <h5>Fine-grained</h5>
      <div>Renders: {renderCount}</div>
      <div>Count: <Memo>{count$}</Memo></div>
    </FlashingDiv>
  )
}
Live Editing

For isolating a group of elements or computations, Legend-State has built-in helpers to easily extract children so that their changes do not affect the parent. This keeps large parent components from rendering often just because their children change.

import { useRef } from "react"
import { useInterval } from "usehooks-ts"
import { Memo, useObservable } from "@legendapp/state/react"

function MemoArrayExample() {
  const renderCount = ++useRef(0).current
  const messages$ = useObservable([])

  useInterval(() => {
    messages$.splice(0, 0, `Message ${messages$.length + 1}`)
  }, 600)

  return (
    <Box>
      <h5 className="renders">Renders: {renderCount}</h5>
      <div className="messages">
        <Memo>
          {() => (
            messages$.map((m, i) => (
              <div key={i}>{m}</div>
            ))
          )}
        </Memo>
      </div>
    </Box>
  )
}
Live Editing

👷 Does not hack React internals

Some libraries hack up React internals to make signals and fine-grained reactivity work, which often doesn’t work on all platforms and may break if React internals change.

Legend-State does everything above-board using hooks, with all React functionality built on top of useSelector, which just uses useSyncExternalStore. Check the source to see the lack of hackery.

🤷‍♀️ Unopinionated

Some state libraries are for global state while some want state to reside within React. Some enourage individual atoms and others are for large global stores. Some have “actions” and “reducers” and others require immutability. But you can use Legend-State any way you want.

  • Global state or local state in React: Up to you 🤷‍♀️
  • Individual atoms or one store: Up to you 🤷‍♀️
  • Modify directly or in actions/reducers: Up to you 🤷‍♀️

See Patterns for more examples of different ways to use Legend-State.

💾 Persistence built in

There are only two hard things in Computer Science: cache invalidation and naming things. - Phil Karlton

We don’t want developers to have to worry about persisting and syncing state, because it’s often very complicated and error-prone. So we’ve built persistence plugins using Legend-State’s listeners, with extensive tests to make sure it’s absolutely correct.

It currently includes plugins for local persistence with Local Storage and IndexedDB on web and react-native-mmkv in React Native, and remote sync plugins for Firebase Realtime Database, TanStack Query, and fetch.

import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'
import { ObservablePersistFirebase } from "@legendapp/state/persist-plugins/firebase"
import { persistObservable } from '@legendapp/state/persist'
import { observable } from '@legendapp/state'

const state$ = observable({ store: { bigObject: { ... } } })

// Persist this observable
persistObservable(state$, {
    pluginLocal: ObservablePersistLocalStorage,
    local: 'store',
    pluginRemote: ObservablePersistFirebase,
    remote: {
        firebase: {
            refPath: (uid) => `/users/${uid}/`,
            requireAuth: true,
        },
    }
})

🔫 It’s safe from footguns

Observables prevent direct assignment, favoring more purposeful set and assign functions instead. Read more in safety.