Skip to content

Latest commit

Β 

History

History
409 lines (304 loc) Β· 11.8 KB

File metadata and controls

409 lines (304 loc) Β· 11.8 KB
title πŸ’Ύ Simple store
description A reactive store that combines the simplicity of signals with the power of "selectors" you'd find in Zustand or Redux.
sidebar
label order
Get started
1

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

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

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

API

store(initial)

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

  • Parameters: initial: number | string | boolean | null | undefined | object
  • 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");

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)
  • 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.