Library / SDK
lostpebble/pullstate avatar
lostpebble/pullstate

lostpebble/pullstate: React state stores you read with a selector and mutate with Immer

Simple state stores using immer and React hooks - re-use parts of your state by pulling it anywhere you like!

1,069 stars22 forksTypeScriptMIT

At a glance

What is it?
pullstate is a MIT licensed TypeScript library of simple state stores for React, about seven kilobytes minified and gzipped, where a store is created with `new Store()` outside the component tree, read through a hook with a selection function, and updated by mutating the state directly thanks to Immer. It also handles server side rendering and async actions with Suspense.
Who is it for?
Adopt pullstate if you want shared React state without a large store dependency, and you are comfortable with the model where state lives in module scope rather than in a provider tree, since that is the whole design and it is what makes the 7KB footprint possible. Do not adopt it if you need a framework independent store, a devtools integration, or a store API that is not tied to React hooks.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 144 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.

Editorial analysis

State that lives outside the component tree

pullstate is described as ridiculously simple state stores with performant retrieval anywhere in your React tree using the wonderful concept of React hooks, and the project description is the friendliest summary of the design: simple state stores using immer and React hooks, re-use parts of your state by pulling it anywhere you like.

The core idea is that a store is an ordinary object created at module scope, not a value provided by a context provider. You define the first state store by passing an initial state to `new Store()`:

jsx
import { Store } from "pullstate";

export const UIStore = new Store({
  isDarkMode: true,
});

The store then lives in the module, and any component that imports it can read from it and write to it. The README calls this omnipresent state updating and describes the benefit plainly. We have freed our app's state from the confines of the component, which is one of the main advantages of Pullstate, allowing state concerns to be separated from being locked in at the component level and managed at a more global level from which components listen and react.

That is a real trade, not a free win. The usual React model puts shared state in a provider near the top of the tree, which makes the ownership visible and makes testing and server rendering straightforward. pullstate's model removes the provider, and with it the explicit ownership, in exchange for a store that can be imported from anywhere, including from a module that has nothing to do with React.

The library is written in TypeScript, and the README notes that the size is about 7KB minified and gzipped, excluding Immer and React themselves.

useState with a selection function

Reading a store uses a hook with the same name as the React state hook, `useState()`, and the argument is a selection function.

tsx
import * as React from "react";
import { UIStore } from "./UIStore";

export const App = () => {
  const isDarkMode = UIStore.useState(s => s.isDarkMode);

  return (
    <div
      style={{
        background: isDarkMode ? "black" : "white",
        color: isDarkMode ? "white" : "black",
      }}>
      <h1>Hello Pullstate</h1>
    </div>
  );
};

The selection function is the performance story. The README explains that the argument is a selection function that ensures we select only the state that we actually need for this component, and that this is a big performance booster, because the component only listens for changes, and re-renders if changed, on the exact returned values, in this case simply the value of `isDarkMode`.

That is the same idea as a reselect style selector, and it is the piece that makes a global store viable. Without it, every component subscribed to a store would re-render on every change to that store, and a global store that is a convenience becomes a performance problem in a large tree.

The name collision with React's own `useState` is worth flagging for readers, since the hook is called on the store object rather than being imported, so the two do not actually clash, but the resemblance makes the documentation worth reading carefully.

Updating by mutating, because Immer is underneath

Writing to the store is an `update()` call with a function that mutates the state directly.

tsx
        onClick={() =>
          UIStore.update(s => {
            s.isDarkMode = !isDarkMode;
          })
        }>

The README is explicit that we call `update()` on the store, inside which we directly mutate the store's state, and that this is all thanks to the power of Immer. Immer is the library that lets a mutation be recorded as a new immutable state, so the code reads as imperative mutation while the library underneath produces the new value and knows what changed.

The pattern also encourages extracting the action into a named function, which is a better shape for anything that is triggered from more than one place:

tsx
function toggleMode(s) {
  s.isDarkMode = !s.isDarkMode;
}

// ...in our <button> code
<button onClick={() => UIStore.update(toggleMode)}>Toggle Dark Mode</button>

The library's dependency list is short as a result: Immer, React and the small amount of plumbing that makes the hook work. The `package.json` publishes a CommonJS build, an ES module build, a UMD build and a minified UMD build, with type declarations, and the build is a Rollup configuration with terser for the minified variant.

The history is also worth knowing, because it explains the shape. The library was originally inspired by the now seemingly abandoned bey library, which was in turn inspired by react-copy-write, and the README notes that although it is substantially different now, with server side rendering and async actions built in, it descends from that lineage.

Async actions, Suspense and server side rendering

The newest headline feature is the ability to create async actions and use React hooks or `<Suspense/>` to have complete control over their UI states.

That is the part of the library that addresses the part of async work that state libraries usually leave to the component. With a plain store, a component that loads data needs its own loading and error state, and the state library gives you nothing for that. With async actions in the store, the pending state lives where the action was defined, and the component reads the outcome through a hook or through Suspense, which is why the documentation has a section on async action hooks in addition to creation and usage.

The documentation site is structured around four journeys, an installation page, a quick example, a quick example for server rendering, and the async actions introduction with its three sub pages, and there is a separate server side rendering example. Server side rendering support is the other structural difference from the library it grew out of, and the topics list confirms it as a feature.

The site is built with Docusaurus and is Dockerised in the repository. The compose file runs a `docusaurus` service built from the local Dockerfile, exposing ports 3000 and 35729 and bind mounting the `docs/` and several `website/` directories, so the documentation can be edited and previewed without a full rebuild. The Dockerfile itself is on node 8.11.4, which is a very old base image and a small maintenance smell in a repository that otherwise looks carefully maintained.

Size budget, benchmarks and typedoc

The engineering around the library is more thorough than the marketing suggests, and the `package.json` scripts are the evidence.

There is a `check-size` and a `check-size-cjs` script, each running `minified-size` over the built ES module and the CommonJS build respectively. That is the 7KB figure turned into a build time check, so the size claim in the README is a thing the build verifies rather than a number that was true once.

There is a benchmark suite with six entry points, `benchmark-all`, `benchmark-async-argument`, `benchmark-destructuring`, `benchmark-all-immer`, `benchmark-immer-without-stores` and `benchmark-immer-with-stores`. The names show what the author was actually interested in, namely the cost of Immer itself, whether a store is involved, how arguments are passed and how destructuring performs, since those are the questions that decide whether this approach is fast enough for a real application. The benchmarks run through ts-node with a raised heap limit of 4096 megabytes.

Testing is with Jest, with a watch variant, and the documentation for the API is generated with typedoc, with `typedoc.json` and a separate `typedoc.tsconfig.json` in the tree, plus a `generate-typedoc` script pointing at the package entry point.

The build itself is a Rollup configuration with a clean step through rimraf, and the repository uses yarn, with a `yarn.lock` committed, a Prettier configuration, a `Changelog.md` and a `test/` directory that holds both the unit tests and the benchmark files.

Where pullstate is the wrong choice

The first wrong case is a project that is not React. The store reads through a React hook and the whole model is hooks and re-renders, so there is no framework independent core to reuse in a Vue or Svelte application or in a worker.

The second is a team that wants a store with a development tools ecosystem. pullstate's surface is `new Store`, `useState` and `update`, plus the async action machinery. There is no time travel, no action log, no middleware pipeline and no devtools integration described in the documentation, so debugging a state change means reading the code.

The third is a codebase that already has a reducer based pattern with context. A reducer plus a context provider is a few dozen lines, it is understood by every React developer, and it keeps the ownership of shared state visible in the tree. pullstate is for the case where that has become annoying, and adopting it in a project that has not hit that annoyance adds a dependency and a mental model for nothing.

The fourth is a project sensitive to the cost of Immer. Every update goes through Immer, which is where the immutability and the change detection come from, and the benchmark scripts in the repository are an honest admission that this is a cost worth measuring rather than a free lunch. For a store updated on every animation frame, that measurement matters.

On maintenance, the picture needs reading carefully. The package file declares version 1.25.0, while the newest GitHub release tag is 1.21.0 from 2021-03-07, with 1.20.1 and 1.20.0 from late 2020 before it. The last push to the repository was on 2026-06-02, and the repository is not archived, so the code is being touched, and the npm version has moved past the last tag. That is a project publishing to npm without cutting GitHub releases, which is fine for consumers and slightly awkward for anyone who wants to map a version to a commit.

Editorial conclusion

Adopt pullstate if you want shared React state without a large store dependency, and you are comfortable with the model where state lives in module scope rather than in a provider tree, since that is the whole design and it is what makes the 7KB footprint possible. Do not adopt it if you need a framework independent store, a devtools integration, or a store API that is not tied to React hooks. Verify first that the version you install is the one you expect, because the package file says 1.25.0 while the newest GitHub release tag is 1.21.0 from 2021, that async actions work with the React version you are on, and that Immer's structural sharing is acceptable for your update patterns, since every mutation goes through it.

Frequently asked questions

How do I create and read a store with pullstate?

Create the store at module scope with `new Store({ isDarkMode: true })`, then read from it in a component with the store's own hook, `UIStore.useState(s => s.isDarkMode)`. The selection function ensures the component only listens for changes to the values it actually selected.

How do I update state in pullstate?

Call `update()` on the store with a function that mutates the state directly, for example `UIStore.update(s => { s.isDarkMode = !isDarkMode })`. The mutation is safe because Immer produces the new immutable state underneath, and the README suggests extracting the action into a named function when it is used from more than one place.

Does pullstate support async actions and server side rendering?

Yes. The library added async actions that can be used with React hooks or `<Suspense/>` to control their UI states, and server side rendering is a documented feature with its own example, along with an installation page, a quick example and the async action documentation on the project site.

How big is pullstate?

The README states about 7KB minified and gzipped, excluding Immer and React themselves, and the package scripts include `check-size` and `check-size-cjs` targets that run `minified-size` over the built bundles, so the size is checked as part of the build.

What are the latest versions of pullstate?

The package file declares version 1.25.0, while the most recent GitHub release tag is 1.21.0 from 2021-03-07, with 1.20.1 and 1.20.0 from late 2020. The last push to the repository was on 2026-06-02, and the repository is not archived.

Official sources

  1. License: MIT
  2. lostpebble/pullstate on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/lostpebble-pullstate.svg)](https://hysenlabs.com/projects/lostpebble-pullstate)

Was this useful?