Skip to content

Latest commit

Β 

History

History
566 lines (427 loc) Β· 17.7 KB

File metadata and controls

566 lines (427 loc) Β· 17.7 KB
title πŸ’Ύ Simple store
description Simple Store (@simplestack/store) is a lightweight reactive state library for React, Next.js, and Vite. Combines signal-like simplicity with Zustand-style selectors and nested sub-stores.
sidebar
label order
Get started
1
head
tag attrs content
script
type
application/ld+json
{ "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [ { "@type": "Question", "name": "How do I install Simple Store?", "acceptedAnswer": { "@type": "Answer", "text": "Install from npm with: npm i @simplestack/store. Then import the store function: import { store } from '@simplestack/store'." } }, { "@type": "Question", "name": "Does Simple Store work with Next.js?", "acceptedAnswer": { "@type": "Answer", "text": "Yes. Simple Store is compatible with Next.js App Router. Stores initialize once per server request and client components hydrate with the initial value. Any component using useStoreValue must be a 'use client' component." } }, { "@type": "Question", "name": "What are sub-stores in Simple Store?", "acceptedAnswer": { "@type": "Answer", "text": "Sub-stores let you operate on specific parts of a nested store by calling select('key') on the parent. Changes to a sub-store automatically update the parent, and vice versa." } } ] }

import { LinkCard, Tabs, TabItem } from '@astrojs/starlight/components';

Simple Store (@simplestack/store) is a lightweight reactive state management library for React, Next.js, and Vite applications. It combines the simplicity of signals with the power of "selectors" you'd find in Zustand or Redux β€” letting you create stores, select nested values with select(), and subscribe to fine-grained updates.

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");

Installation

Install the dependency from npm:

npm i @simplestack/store

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

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

Usage

1. Create a 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);

2. Set the value of a store

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);
}

3. Use the store in a component

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.

```tsx "useStoreValue" // 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>
  );
}
```
:::note
Any component using `useStoreValue` must be a `"use client"` component.
:::

```tsx "useStoreValue"
// app/components/Counter.tsx
"use client";

import { useStoreValue } from "@simplestack/store/react";
import { counterStore } from "@/lib/counter";

export default 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

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.

:::info 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:

```tsx ins={6} // 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>
  );
}
```
```tsx ins={8} // app/components/ThemeToggle.tsx "use client";
import { useStoreValue } from "@simplestack/store/react";
import { themeStore } from "@/lib/user";

export default function ThemeToggle() {
  const theme = useStoreValue(themeStore);
  return (
    <button onClick={() => themeStore.set(theme === "dark" ? "light" : "dark")}>
      Theme: {theme}
    </button>
  );
}
```

:::tip useStoreValue also accepts a selector function for dynamic keys or computed values:

const title = useStoreValue(documentStore, (s) => s.notes[id]?.title);

πŸ“š See the API reference to learn more. :::

Next.js support

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

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.

:::note You cannot call useStoreValue() in a server component, since subscriptions are only available in client components. :::

// 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>;
}

Middleware

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.

Built-in logger middleware

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] });

Implementing custom middleware

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] });

API

store(initial, options?)

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] });

StoreMiddleware

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());
  },
});

StoreOptions

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>[]

loggerMiddleware

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] });

store.destroy()

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

React

useStoreValue(store, selector?)

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>;
}

useShallow(selector)

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))
);

Type Reference

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.