Skip to content

💾 Simple store

A reactive store that combines the simplicity of signals with the power of “selectors” you’d find in Zustand or Redux.

import { store } from "@simplestack/store";
const documentStore = store({
title: "Untitled",
description: "Description",
tags: [{ name: 'cooking' }],
});
const title = documentStore.select("title");
const description = documentStore.select("description");
title.set("New title");
console.log(title.get()); // "New title"
description.set("New description");
console.log(description.get()); // "New description"

If you need to select a nested value, you can pass the complete object path as arguments to the select() function like so. This works for both object keys and array indices:

const tags = documentStore.select("meta", "tags");

Install the dependency from npm:

Terminal window
npm i @simplestack/store

Then, import the store and use it in your component:

import { store } from "@simplestack/store";

You can create a store using the store() function, passing an initial value as an argument.

We suggest creating stores outside of components so they aren’t recreated on each render:

import { store } from "@simplestack/store";
export const counterStore = store(0);

You can set the value of a store by calling the set() method. This accepts both a value and a function that returns the new value:

counterStore.set(1);
counterStore.set((n) => n + 1);

This can be called both from within a component and from outside of a component. This allows you to create utility functions that operate on a store:

import { store } from "@simplestack/store";
export const counterStore = store(0);
export function incrementCounter() {
counterStore.set((n) => n + 1);
}

You can subscribe to the value fo a store from your React components using the useStoreValue hook. This accepts the store as an argument and returns the current value of the store.

src/components/Counter.tsx
import { useStoreValue } from "@simplestack/store/react";
import { counterStore } from "../stores/counter";
export function Counter() {
const count = useStoreValue(counterStore);
return (
<button onClick={() => counterStore.set((n) => n + 1)}>
Count: {count}
</button>
);
}

4. Create sub-stores for fine-grained updates

Section titled “4. Create sub-stores for fine-grained updates”

As your store grows more complex, you may want to operate on specific parts of the store.

In this example, say we have a store to track a user’s preferences, including their theme. Naively, you can operate on nested values by calling .set() and reconstructing the nested object using spread syntax, like so:

const userStore = store({
name: "Guest",
preferences: { theme: "dark" },
});
function setTheme(theme: string) {
userStore.set((state) => ({
...state,
preferences: { ...state.preferences, theme },
}));
}

However, this is fairly verbose and error-prone. Instead, you can create “sub-stores” by calling select('key') on the parent store, where key is the object key or array index you want to select. This creates a new store instance that lets you operate on the selected object key.

In this example, we can create a sub-store for the preference key, and operate on the theme value:

const userStore = store({
name: "Guest",
preferences: { theme: "dark" },
});
const preferencesStore = userStore.select("preferences");
const themeStore = preferencesStore.select("theme");

You can simplify this even further by passing the complete object path ('preferences', 'theme') as arguments to the select() function:

const preferencesStore = userStore.select("preferences");
const themeStore = preferencesStore.select("theme");
const themeStore = userStore.select("preferences", "theme");

Then, you can update the user’s theme preference by calling set() on the sub-store directly:

function setTheme(theme: string) {
userStore.set((state) => ({
...state,
preferences: { ...state.preferences, theme },
}));
themeStore.set(theme);
}

Changes to themeStore automatically update userStore, and vice versa.

If a select() path crosses a potentially undefined value at runtime (for example, preferences is missing), set() is discarded for safety and a warning is logged in dev mode.

You can then subscribe to a sub-store the same way you subscribe to a parent store. Pass the sub-store to the useStoreValue hook:

src/components/ThemeToggle.tsx
import { useStoreValue } from "@simplestack/store/react";
import { themeStore } from "../stores/user";
export function ThemeToggle() {
const theme = useStoreValue(themeStore);
return (
<button onClick={() => themeStore.set(theme === "dark" ? "light" : "dark")}>
Theme: {theme}
</button>
);
}

Simple store is compatible with Next.js, and is built to handle server-side rendering and client-side hydration gracefully.

  • Stores initialize once per server request, making them safe for App Router usage
  • Client components hydrate with the store’s initial value, preventing mismatch issues

Special considerations for server components

Section titled “Special considerations for server components”

Stores are built to be reactive in client contexts, and should not be manipulated in server components.

To sync a value from a server component to a store, use the useEffect hook to update the store from a client component when it mounts:

app/page.tsx
"use client";
import { useEffect } from "react";
import { userStore } from "@/lib/user";
export default function UserProvider({ serverUser }: { serverUser: User }) {
useEffect(() => {
userStore.set(serverUser);
}, [serverUser]);
return null;
}

If you need to read the current value of a store in a server component, you can use the get() method. This returns the current value of the store when the component is being rendered.

app/page.tsx
import { counterStore } from "@/lib/counter";
export default function Page() {
const count = counterStore.get(); // OK: read-only on server
return <p>Server-rendered count: {count}</p>;
}

You may need to run some code that’s separate from your application lifecycle when a store updates. This includes persisting to local storage or logging updates to the console. You may also need to monitor and manipulate the store’s value directly, as in developer tooling or integration with state management solutions like Immer.

For each of these, you can reach for middleware. This lets you hook into the store’s lifecycle, meaning whenever a value is set, and whenever the store is initialized.

To use the built-in logger middleware, pass loggerMiddleware in the middleware array when creating the store:

import { loggerMiddleware, store } from "@simplestack/store";
const countStore = store(0, { middleware: [loggerMiddleware] });

This example shows how to implement a custom middleware that persists to localStorage.

You can import the StoreMiddleware type from @simplestack/store to create your own middleware. For this use case, you can use the set property to wrap the set() call and persist the store’s value:

import { store } from "@simplestack/store";
import type {
StateObject,
StatePrimitive,
Store,
StoreMiddleware,
} from "@simplestack/store";
const localStorageMiddleware = <T extends StateObject | StatePrimitive>(
store: Store<T>
): ReturnType<StoreMiddleware<T>> => ({
set: (next) => (setter) => {
next(setter);
if (typeof window === "undefined") return;
window.localStorage.setItem("counter", JSON.stringify(store.get()));
},
});
const counterStore = store({ count: 0 }, { middleware: [localStorageMiddleware] });

You will also want to check for existing values in localStorage and initialize the store with them if they exist. To do this, you can use the init property to run code when the store is initialized:

import { store } from "@simplestack/store";
import type {
StateObject,
StatePrimitive,
Store,
StoreMiddleware,
} from "@simplestack/store";
const localStorageMiddleware = <T extends StateObject | StatePrimitive>(
store: Store<T>
): ReturnType<StoreMiddleware<T>> => ({
set: (next) => (setter) => {
next(setter);
if (typeof window === "undefined") return;
window.localStorage.setItem("counter", JSON.stringify(store.get()));
},
init: () => {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem("counter");
if (raw) store.set(JSON.parse(raw));
},
});
const counterStore = store({ count: 0 }, { middleware: [localStorageMiddleware] });

Creates a store with get, set, subscribe, and (for objects and arrays) select.

  • Parameters: initial: number | string | boolean | null | undefined | object
  • Parameters: options?: StoreOptions<T>
  • Returns: Store<T> where T is inferred from initial or supplied via generics
import { store } from "@simplestack/store";
const counter = store(0);
counter.set((n) => n + 1);
console.log(counter.get()); // 1
// Select parts of a store for objects and arrays
const doc = store({ title: "x" });
const title = doc.select("title");

With middleware:

import { store, loggerMiddleware } from "@simplestack/store";
const counter = store(0, { middleware: [loggerMiddleware] });

Middleware can wrap set() and/or run initialization logic. This is useful when you need to log updates, persist data, or wire in side effects. Use set to wrap updates, and init for startup work that can optionally return a cleanup function.

  • Signature: (store: Store<T>) => { set?: (next) => (setter) => void; init?: () => void | (() => void) }
import type { StoreMiddleware } from "@simplestack/store";
const middleware: StoreMiddleware<number> = (store) => ({
set: (next) => (setter) => {
next(setter);
console.log("new value", store.get());
},
});

Options you can pass to store() or select(). This is useful when you want to add middleware to the root store or a selected slice.

  • middleware?: StoreMiddleware<T>[]

Built-in middleware that logs previous and next values on each set(). Use this for quick debugging without custom middleware.

import { loggerMiddleware, store } from "@simplestack/store";
const countStore = store(0, { middleware: [loggerMiddleware] });

Runs any cleanup functions returned by middleware init() hooks. Use this to dispose subscriptions or external connections created by middleware.

React hook to subscribe to a store and get its current value. Optionally pass a selector function to derive a value from the store.

  • Parameters:
    • store: Store<T> | undefined
    • selector?: (state: T) => R - optional function to select/compute a value
  • Returns: R | T | undefined
import { store } from "@simplestack/store";
import { useStoreValue } from "@simplestack/store/react";
const counterStore = store(0);
function Counter() {
const counter = useStoreValue(counterStore);
return (
<button onClick={() => counterStore.set((n) => n + 1)}>{counter}</button>
);
}

With a selector:

const documentStore = store({
notes: {
"1": { title: "First" },
"2": { title: "Second" },
},
});
function NoteTitle({ id }: { id: string }) {
// Only re-renders when this specific note's title changes
const title = useStoreValue(documentStore, (s) => s.notes[id]?.title);
return <h1>{title}</h1>;
}
function NoteCount() {
// Compute derived values inline
const count = useStoreValue(documentStore, (s) => Object.keys(s.notes).length);
return <span>{count} notes</span>;
}

Wraps a selector with shallow equality comparison. Use this when your selector returns a new array or object reference on each call.

The problem: selectors that return new references (like Object.values(), array filter(), or object spreads) cause infinite re-renders because React sees a “new” value each time.

import { useStoreValue, useShallow } from "@simplestack/store/react";
// ❌ BAD: Creates new array reference each render → infinite loop
const [title, author] = useStoreValue(noteStore, (s) => [s.title, s.author]);
// ✅ GOOD: useShallow compares array contents, stable reference
const [title, author] = useStoreValue(noteStore, useShallow((s) => [s.title, s.author]));

More examples:

// Filtering creates new array
const drafts = useStoreValue(
docStore,
useShallow((s) => s.notes.filter((n) => n.isDraft))
);
// Spreading creates new object
const meta = useStoreValue(
docStore,
useShallow((s) => ({ title: s.title, author: s.author }))
);
// Object.keys/values/entries create new arrays
const ids = useStoreValue(
docStore,
useShallow((s) => Object.keys(s.notes))
);

These types are exported for TypeScript users.

  • StateObject: Record<string | number | symbol, any>
  • StatePrimitive: string | number | boolean | null | undefined
  • Setter: T | ((state: T) => T)
  • StoreMiddleware: (store: Store<T>) => { set?: (next) => (setter) => void; init?: () => void | (() => void) }
  • StoreOptions: { middleware?: StoreMiddleware<T>[] }
  • Store:
    • get(): T - Get the current value of the store.
    • set(setter: Setter<T>): void - Set the value directly or by using a function that receives the current state.
    • subscribe(callback: (state: T) => void): () => void - Subscribe with a callback. Returns an unsubscribe function.
    • select(...path: (string | number | symbol)[]): Store<...> (present only when T is an object or array) - Select one or more keys/indices. Returns a nested Store (type inferred from the path).
    • getInitial(): T - Get the initial state the store was created with. Used internally for SSR resume-ability.
    • destroy(): void - Run cleanup from middleware init() hooks.