npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tardigrade-store

v1.9.0

Published

A simple state management library

Readme

Tardigrade

State that survives everything. Tardigrade is a small, typed state manager whose core has no dependencies — and around that core you add only the pieces you actually need: persistence, undo/redo, write rules, and bridges for React, Vue and Svelte. Each one is a separate import, so a project that just needs a store stays tiny

The name isn't accidental. A tardigrade is the animal that survives anything, and that's the idea here: your state survives reloads (persist), user mistakes (history undo/redo), bad data (ward rules) and even parts of the app being torn down (merge / lifecycle)

Why this library exists

Most state managers make you choose: either something minimal that you outgrow the moment you need persistence or validation, or a full framework with actions, reducers and boilerplate for a counter. Tardigrade tries to sit in between — a plain store you can read and write directly, with strict per-property typing and dynamic properties, that you can grow into a serious setup by attaching optional layers instead of rewriting anything

The core stays deliberately lean; everything heavier lives in its own subpath export and plugs into a tiny extension point


What you get

A typed store, without the ceremony. Read and write properties directly (store.setProp, store.prop), no actions or reducers. Each property locks its type on creation, and TypeScript infers the shape from your initial data so autocomplete and checks just work — while dynamic, runtime-added properties are still allowed

Reactivity that fits the framework. Property-specific, global and derived (selector) listeners on the core, plus ready-made bridges: React (useSyncExternalStore, concurrent-safe), Vue 3 (composables returning refs, v-model friendly) and Svelte (native store contract, $-auto-subscription). Batched updates via setProps notify once, not per key

Optional layers that compose. Attach only what you need — they all share the same store and stack naturally:

  • persist — snapshot state to localStorage (or any adapter) with versioning and migrations
  • history — snapshot-based undo/redo with a redo branch and a step limit
  • ward — rules that run before a write to allow, deny or transform the value

Predictable data. Values are cloned in and out, so nothing outside can mutate the store's internals; object props keep referential stability, so equal content never triggers a spurious re-render. Resolvers cover async and derived data, and the lifecycle (merge, kill, automatic listener cleanup) keeps things tidy when parts of the app come and go

Full history of changes lives in changelog.md


Basic usage

Creating an Instance

To start using the library, first create an instance of it:

import { createTardigrade } from "tardigrade-store";

const tardigrade = createTardigrade();

Adding Properties

You can dynamically add properties to the state using the addProp method. Each property has a type that is locked once it is added.

tardigrade.addProp("counter", 0);  // Adds a property 'counter' with an initial value of 0
tardigrade.addProp("username", "guest");  // Adds a property 'username' with an initial value of 'guest'

Handle Properties

To update a property, use the setProp method. The new value must match the type of the property that was originally set:

tardigrade.setProp("counter", 5);  // Updates 'counter' to 5
tardigrade.setProp("username", "admin");  // Updates 'username' to 'admin'

You can also set a property to null without changing its type:

tardigrade.setProp("username", null);  // Sets 'username' to null, but its type remains string

Batch updates

To update several props at once use setProps. All values are written first, then listeners are notified: each prop listener gets its new value, but global listeners are called only once per batch instead of once per change

tardigrade.setProps({
    counter: 5,
    username: "admin",
});

Global listener receives a batched update in a special form: array of changed names and a dictionary of changed values

tardigrade.addListener((name, value, props) => {
    if (Array.isArray(name)) {
        // batched update via setProps
        console.log("Changed props:", name, value);
        return;
    }

    console.log(`Prop ${name} changed to`, value);
});

Props that didn't actually change (same scalar value) are excluded from the batch, and if nothing changed listeners aren't called at all. Values are validated the same way as in setProp: wrong types or unknown names are reported and skipped without breaking the rest of the batch

And if you need to get prop you can tackle it with prop method:

tardigrade.setProp("counter", tardigrade.prop("counter") + 1);

It is also possible to get all the props as simple object with getter props

console.log(tardigrade.props); // will print all the props

To check was prop added use hasProp method

tardigrade.addProp("role", "customer");
console.log(tardigrade.hasProp("role")); // return true

Listening to Property Changes

There are two types of listeners you can add:

  1. Property-Specific Listeners: These listen to changes on a specific property
  2. Global Listeners: These listen to changes across all properties

Property-Specific Listener

To listen for changes on a specific property, use the addPropListener method:

const propListener = (value) => console.log("Counter changed to", value);
tardigrade.addPropListener("counter", propListener);

Whenever the counter property is updated, the propListener will be called with the new value

Global Listener

To listen for any change across all properties, use the addListener method:

const globalListener = (name, value, props) => {
    console.log('Global update');
    console.log(`Property ${name} changed to, ${value}`);
    console.log('All props', props);
};

tardigrade.addListener(globalListener);

This global listener is triggered whenever any property in the library is updated

Removing Listeners

Listeners can be removed to avoid memory leaks or when they are no longer needed

Remove Property-Specific Listener

To remove a specific listener for a property, use the removePropListener method:

tardigrade.removePropListener("counter", propListener);

Remove All Property-Specific Listeners

If you need to remove all property-specific listeners at once, you can call removeAllPropListeners:

tardigrade.removeAllPropListeners("counter");

Remove Global Listener

Similarly, you can remove global listeners with removeListener:

tardigrade.removeListener(globalListener);

Remove All Global Listeners

If you need to remove all global listeners at once, you can call removeAllListeners:

tardigrade.removeAllListeners();

Removing Properties

You can dynamically remove properties using the removeProp method. This will also automatically remove all listeners attached to the property

tardigrade.removeProp("username");  // Removes 'username' property and its listeners

Once a property is removed, any attempt to update it will result in an error:

tardigrade.setProp("username", "newUser");  // Throws an error since 'username' was removed

You can also remove all the props by single hit with removeAllProps. It is also remove all props listeners

tardigrade.removeAllProps();

Import props

You can import props of another Tardigrade store into yours with the importProps method. This will copy props from the target store into your store

const altStore = createTardigrade();
altStore.addProp("timestamp", 0);

const tardigrade = createTardigrade();
tardigrade.importProps(altStore);  // "timestamp" prop is copied into our store

importProps method replace only props, without prop's listener handlers

There are two ways to import props: without override or with

tardigrade.importProps(altStore, true);  // Will override existing props of this store after import
tardigrade.importProps(altStore); // Won't override existing props of this store after import

Merge stores

You can merge one store into another. Merging replace not only props but all the listener handlers and make source object stop working

const altStore = createTardigrade();
altStore.addProp("timestamp", 0);
altStore.addPropListener("timestamp", (value) => console.log(value));

const tardigrade = createTardigrade();
tardigrade.merge(altStore);  // Replaced "timestamp" prop and all the prop listener handlers

console.log(altStore.prop("timestamp")); // returns null because altStore was killed after merging

As importProps this method also has two ways to be executed

tardigrade.merge(altStore, true);  // Will override existing props and listener handlers of this store after import
tardigrade.merge(altStore); // Won't override existing props and listener handlers of this store after import

Core kills merged store to keep single truth origin, but deactivated store after merging get link to mergeAgent - store which was merged and killed it. Also, it can be null if store is active

const currentStore = altStore.mergeAgent || altStore;

Full Example

Here’s how you can combine everything:

import { createTardigrade } from "tardigrade-store";

const tardigrade = createTardigrade();

// Add properties
tardigrade.addProp("counter", 0);
tardigrade.addProp("username", "guest");

// Add listeners
const propListener = (value) => console.log("Counter changed to", value);
tardigrade.addPropListener("counter", propListener);

const globalListener = (name, value, props) => {
    console.log(`Global: ${name} changed to, ${value}`);
};

tardigrade.addListener(globalListener);

// Update properties
tardigrade.setProp("counter", 5);
tardigrade.setProp("username", "admin");

// Remove listeners
tardigrade.removePropListener("counter", propListener);
tardigrade.removeListener(globalListener);

// Remove a property
tardigrade.removeProp("username");

Getter isAlive(): boolean

You can check activity of your store with isAlive getter

console.log(tardigrade.isAlive); // will print store state

Reset

In any moment you can reset your alive store to empty state. All props, resolvers, global and local listeners will be removed

tardigrade.reset();

TypeScript typing

The store shape can be locked at compile time. Pass an interface (or just let TypeScript infer it from initial data) and props/resolvers become strictly typed: known prop names get autocomplete, values are type-checked, and resolver listeners receive the awaited return type of the resolver

const store = createTardigrade({
    counter: 0,
    username: "guest",
    double: ({ counter }: { counter: number }) => counter * 2,
});

store.setProp("counter", 5); // ok
store.setProp("counter", "text"); // compile error: string is not assignable to number

const counter = store.prop("counter"); // typed as Nullable<number>

store.addResolverListener("double", (value) => {
    // value is typed as Nullable<number>
});

You can also declare the shape explicitly without initial data:

interface StoreShape {
    counter: number;
    username: string;
}

const store = createTardigrade<StoreShape>();

store.addProp("counter", 0); // ok
store.addProp("counter", "text"); // compile error

Dynamic props keep working: any name that isn't declared in the shape falls back to any, so nothing about the runtime flexibility is lost. And plain createTardigrade() without a shape behaves exactly as before


Resolvers

Resolver - is a function which can be pass into store and be called in certain moment to bring some value. Application can follow resolvers as props to control state

Simple resolver usage

import { createTardigrade } from "tardigrade-store";

const tardigrade = createTardigrade();

tardigrade.addResolver("random", () => Math.random());
tardigrade.addResolverListener("random", (value) => {
   console.log(`Resolver was called and bring ${value}`); 
});

tardigrade.callResolver("random"); // as result it will call resolver listener handler above

Income resolver handler can use single argument - all the current props object

import { createTardigrade } from "tardigrade-store";

const tardigrade = createTardigrade();
tardigrade.addProp("money", 1e5);
tardigrade.addResolver("multiplyMoneyRandomly", ({ money }) => Math.random() * money);

tardigrade.addResolverListener("multiplyMoneyRandomly", (value) => {
   console.log(`Resolver was called and bring ${value}`); 
});

tardigrade.callResolver("multiplyMoneyRandomly");

Resolver also migrate as well by merging and can be imported

You can also remove single resolver as well

tardigrade.removeResolver("multiplyMoneyRandomly");

Or you can drop all the resolvers by single hit

tardigrade.removeAllResolvers();

To check was resolver added use hasResolver method

tardigrade.addResolver("getUsers", async () => { /* ...some stuff */ });
console.log(tardigrade.hasResolver("getUsers")); // return true

Usage with async

Also, you can use it with async function to do stuff like that

import { createTardigrade } from "./";

(async () => {
    const tardigrade = createTardigrade();

    const resolverKeys = {
        fetchSomeSpecial: "fetchSomeSpecial",
    };

    tardigrade.addResolver(resolverKeys.fetchSomeSpecial, async () => {
        try {
            const response = await fetch("https://jsonplaceholder.org/posts");
            return response.json();
        } catch (error) {
            console.log('Fetch error', error);
            return null;
            }
    });

    tardigrade.addResolverListener(resolverKeys.fetchSomeSpecial, (fetchedValue) => {
        console.log("Fetched value is", fetchedValue);
    });

    await tardigrade.callResolver(resolverKeys.fetchSomeSpecial);
})();

createTardigrade variants

At the beginning we learn how create basic instance of store. But you are able to use some options to instancing

Initial props and resolvers

Pass json-friendly object as first argument. All what is function would become resolver and all other what is has another type would become prop


import { createTardigrade } from "./";

(async () => {
    const propKeys = {
        counter: "counter",
    };

    const resolverKeys = {
        fetchSomeSpecial: "fetchSomeSpecial",
    };

    const tardigrade = createTardigrade({
        [propKeys.counter]: 0,
        [resolverKeys.fetchSomeSpecial]: async () => {
            try {
                const response = await fetch("https://jsonplaceholder.org/posts");
                    return response.json();
            } catch (error) {
                console.log('Fetch error', error);
                return null;
            }
        },
    });

    tardigrade.addPropListener(propKeys.counter, (counterValue) => {
        console.log(`Counter equals ${counterValue}`);
    });

    tardigrade.addResolverListener(resolverKeys.fetchSomeSpecial, (fetchedValue) => {
        console.log("Fetched value is", fetchedValue);
    });

    await tardigrade.callResolver(resolverKeys.fetchSomeSpecial);

    for (let i = 0; i < 10; i++) {
        tardigrade.setProp(propKeys.counter, tardigrade.prop(propKeys.counter) + 1);
    }
})();

Store initial options

With second argument you can pass some initialised options for store, for instance:

import { createTardigrade } from "./";

(async () => {
    const tardigrade = createTardigrade({
        "counter": 0,
    }, {
        emitErrors: true
    });

    tardigrade.setProp('text', "Lorem ipsum"); // bring real error
})();

There are several options:

emitErrors: boolean - this options control how store react onto errors. If this prop equals true then store is going to crash after any incorrect usage. If false - you get only error-messages in console without real errors. By default, this initial prop equals false

name: string | number | symbol - it is basically name of your store. It can be useful in case you want to get some store from array. By default, name equals random uuid

strictObjectsInterfaces - this parameter specifies how strictly to enforce type-checking on prop that has object type. If the parameter equals true, it's not possible to assign an object to a prop if its interface differs from the expected one

For instance

const propNames = {
    user: "user",
};

const store = createTardigrade({
    [propNames.user]: {
        name: "Alise", // will get string type
        age: 100, // will get number type
        data: null, // will get any type, you can write any type here later
    },
}, 
{
    strictObjectsInterfaces: true
});

store.addPropListener(propNames.user, value => console.log(value));

store.setProp(propNames.user, {
    name: "Bob",
    age: 200,
    data: Symbol("Bob")
});

store.setProp(propNames.user, {
    name: "Frank",
    age: "200",
}); // Bring error cause interface isn't the same

React bridge

React (>=16.8) is an optional peer dependency — install it only if you use the bridge

import { TardigradeProvider, useTardigrade, useTardigradeProp } from "tardigrade-store/react";

useTardigrade(initialData?, initialOptions?)

Creates a store bound to the component lifecycle. The store is created once on the first render and kept between re-renders

import { useTardigrade, useTardigradeProp } from "tardigrade-store/react";

const Counter = () => {
    const store = useTardigrade({ counter: 0 });
    const [counter, setCounter] = useTardigradeProp<number>("counter", store);

    return <button onClick={() => setCounter(counter! + 1)}>{counter}</button>;
};

TardigradeProvider and shared stores

To share a single store across the component tree wrap it with the provider. All the bridge hooks look up the store from context when it isn't passed directly

import { createTardigrade } from "tardigrade-store";
import { TardigradeProvider, useTardigradeProp } from "tardigrade-store/react";

const store = createTardigrade({ username: "guest" });

const UserBadge = () => {
    const [username] = useTardigradeProp<string>("username"); // taken from context
    return <span>{username}</span>;
};

const App = () => (
    <TardigradeProvider store={store}>
        <UserBadge />
    </TardigradeProvider>
);

useTardigradeProp<T>(name, store?)

Subscribes the component to a single prop. Returns a tuple [value, setValue] similar to useState. The component re-renders only when this prop changes

const [counter, setCounter] = useTardigradeProp<number>("counter");

setCounter(5); // same as store.setProp("counter", 5)

useTardigradeProps(store?)

Subscribes to all props at once and returns them as a plain object. The component re-renders on any store update

const props = useTardigradeProps();
console.log(props.counter, props.username);

useTardigradeSelector<T>(selector, store?, isEqual?)

Computes a derived value from props and re-renders the component only when the result changes. This is the middle ground between useTardigradeProp (one key) and useTardigradeProps (any change)

const fullName = useTardigradeSelector((p) => `${p.firstName} ${p.lastName}`);
// changing an unrelated prop (e.g. theme) won't re-render this component

const userSlice = useTardigradeSelector((p) => ({ name: p.user?.name, role: p.user?.role }));
// content-equal slices keep the previous reference, safe for useEffect deps

By default results are compared the same way as object props in the bridge (content equality). You can pass a custom comparison as the third argument:

const items = useTardigradeSelector((p) => p.items, store, (a, b) => a.length === b.length);

Inline selectors are fine — the hook keeps the selector in a ref, so no useCallback is required and the subscription is never re-created

Selectors pair well with setProps: a batch produces a single notification, so the selector recomputes once with the final values

useTardigradeResolver<T>(name, store?)

Wires a resolver into the component. Returns a tuple [callResolver, lastValue]. The last value updates every time the resolver is called — by this component or anywhere else in the app

const [fetchPosts, posts] = useTardigradeResolver<Post[]>("fetchPosts");

useEffect(() => {
    fetchPosts();
}, [fetchPosts]);

useTardigradeStore(store?)

Low-level hook used by the bridge itself. Returns the passed store or the one from the nearest TardigradeProvider, and throws if neither exists. Useful to build custom hooks on top of the bridge

All the bridge hooks unsubscribe automatically on unmount, so there are no leaked listeners

Objects handling

The bridge keeps object props referentially stable: if an update brings an object with the same content, the component doesn't re-render and keeps the previous reference, so it's safe to use hook values in useEffect dependency arrays. Values stored in react state are always clones — mutating them never affects the store internals

Concurrent rendering

Subscription hooks (useTardigradeProp, useTardigradeProps, useTardigradeSelector) are built on useSyncExternalStore, so they are tearing-safe with React 18 concurrent features (startTransition, useDeferredValue, Suspense). On React 16.8–17 the bridge transparently falls back to an internal shim with the same semantics — the peer dependency range stays react >=16.8


Vue bridge

The same bridge for Vue 3 Composition API lives in tardigrade-store/vue. Vue (>=3.0) is an optional peer dependency. The composables mirror the react hooks one to one, but return refs — idiomatic for Vue reactivity

import {
    provideTardigradeStore,
    useTardigrade,
    useTardigradeProp,
    useTardigradeProps,
    useTardigradeSelector,
    useTardigradeResolver,
} from "tardigrade-store/vue";

useTardigradeProp<T>(name, store?)

Returns a writable ref: reading is reactive, writing goes through store.setProp (so core type checks and ward rules apply). Works with v-model out of the box

<script setup lang="ts">
import { useTardigrade, useTardigradeProp } from "tardigrade-store/vue";

const store = useTardigrade({ counter: 0, username: "guest" });
const counter = useTardigradeProp<number>("counter", store);
const username = useTardigradeProp<string>("username", store);
</script>

<template>
    <button @click="counter!++">{{ counter }}</button>
    <input v-model="username" />
</template>

Provide / inject

// parent component setup()
provideTardigradeStore(store);

// any descendant component setup()
const counter = useTardigradeProp<number>("counter"); // store taken via inject

The rest of the composables

const props = useTardigradeProps(store);                       // Ref<Dictionary>, whole snapshot
const fullName = useTardigradeSelector((p) => `${p.first} ${p.last}`, store); // Ref<T>, recomputes only when the result changes
const [fetchPosts, posts] = useTardigradeResolver<Post[]>("fetchPosts", store); // [call, Ref<T | null>]

Semantics match the react bridge: object values are clones with referential stability (content-equal updates don't trigger reactivity), setProps batches arrive as a single update, subscriptions are cleaned up automatically when the component (or effectScope) is destroyed


Svelte bridge

The svelte bridge in tardigrade-store/svelte implements the svelte store contract by hand, so it has zero dependencies — nothing is imported from the svelte package, and it works with $-auto-subscription in Svelte 3, 4 and 5 alike

import { tardigradeProp, tardigradeProps, tardigradeSelector, tardigradeResolver } from "tardigrade-store/svelte";

tardigradeProp<T>(store, name)

A writable store: subscribe / set / update. Writes go through store.setProp, so type checks and ward rules apply

<script lang="ts">
import { createTardigrade } from "tardigrade-store";
import { tardigradeProp } from "tardigrade-store/svelte";

const store = createTardigrade({ counter: 0, username: "guest" });
const counter = tardigradeProp<number>(store, "counter");
const username = tardigradeProp<string>(store, "username");
</script>

<button on:click={() => counter.update((v) => (v ?? 0) + 1)}>{$counter}</button>
<input bind:value={$username} />

The rest of the stores

const props = tardigradeProps(store);                          // readable, whole snapshot
const fullName = tardigradeSelector(store, (p) => `${p.first} ${p.last}`); // readable, notifies only when the result changes
const posts = tardigradeResolver<Post[]>(store, "fetchPosts"); // readable + .call()

await posts.call(); // $posts updates with the resolver result

Semantics match the other bridges: subscribers get the current value synchronously (store contract), object values are clones with referential stability, setProps batches produce a single notification per subscriber, unsubscribe detaches from the store


Persist

Persist snapshots props into a serializable storage (localStorage by default) without changing the store model: it reads store.props, writes snapshots and restores them back through setProp / addProp. Resolvers and listeners are never persisted

import { createTardigrade } from "tardigrade-store";
import { persist } from "tardigrade-store/persist";

const store = createTardigrade({ theme: "light", counter: 0 });

const link = persist(store, {
    key: "my-app",
    pick: (props) => ({ theme: props.theme }),
    saveAfter: 300,
});

store.setProp("theme", "dark"); // auto-saved 300ms later

How it works

Every save writes a full snapshot (not a delta) wrapped in an envelope { version, data }. Auto-save triggers on setProp, setProps and addProp with a debounce (saveAfter ms, 0 means synchronous). Resolver calls don't trigger writes. On start (restoreOnStart, default true) the whole stored blob is merged back into the store: existing props via setProp, unknown ones via addProp

Important: pick applies only on save, restore always merges everything from storage. And since the core doesn't emit events on removeProp / reset, call link.save() explicitly after them

PersistLink methods

link.save();      // write snapshot right now (works even on hold)
link.restore();   // read storage and merge into the store
link.forget();    // remove the key from storage, store untouched
link.peek();      // the snapshot that would be written, without writing

link.hold();      // suspend auto-save for bulk operations
link.unhold();    // resume auto-save and save once

link.retain("draft"); // allowlist for dynamic props (applied on top of pick)
link.drop("draft");
link.pick((props) => ({ theme: props.theme })); // replace pick at runtime

link.dispose();   // detach auto-save; explicit save/restore keep working

Bulk operations without extra writes:

link.hold();
store.addProp("a", 1);
store.removeProp("old");
link.unhold(); // single save with the final state

Migrations

Bump version and provide migrate to upgrade older snapshots:

persist(store, {
    key: "my-app",
    version: 2,
    migrate: (saved, fromVersion) => {
        if (fromVersion < 2) {
            const [firstName, lastName] = saved.fullName.split(" ");
            return { firstName, lastName };
        }
        return saved;
    },
});

Custom storage

Anything with read / write / remove works — the default is localStorage in browsers and an in-memory map elsewhere (SSR, tests):

import { PersistStorage, createInMemoryStorage } from "tardigrade-store/persist";

persist(store, { key: "my-app", storage: createInMemoryStorage() });

React

import { usePersistedTardigrade } from "tardigrade-store/persist/react";

const Settings = () => {
    const link = usePersistedTardigrade({ theme: "light" }, { key: "settings" });
    // link.store is a regular Tardigrade store, use it with bridge hooks
};

// or with an existing store
const link = usePersistedTardigrade(store, { key: "settings" });

The hook creates the link once, restores on the client in an effect (SSR-safe) and detaches auto-save on unmount

Vue

import { usePersistedTardigrade } from "tardigrade-store/persist/vue";

// inside setup(): restore runs in onMounted (client only, Nuxt-safe),
// auto-save detaches when the component or effect scope is destroyed
const link = usePersistedTardigrade({ theme: "light" }, { key: "settings" });

// or with an existing store
const link = usePersistedTardigrade(store, { key: "settings" });

Svelte

import { persistedTardigrade } from "tardigrade-store/persist/svelte";

const link = persistedTardigrade({ theme: "light" }, { key: "settings" });
// link.store is a regular store: wrap its props with tardigradeProp for $-syntax

The factory restores synchronously — the component script runs before the first render, and on the server the default storage is an in-memory no-op. Call link.dispose() in onDestroy if the store doesn't live as long as the app

Persist itself is framework-agnostic: persist(store, options) works with any store no matter which bridge renders it — these wrappers only add lifecycle sugar


History (undo / redo)

History tracks prop changes and rolls them back without changing the store model: it records snapshots of store.props and restores them through setProp / addProp / removeProp. Resolvers, listeners and merge lifecycle are never touched by undo/redo

import { createTardigrade } from "tardigrade-store";
import { history } from "tardigrade-store/history";

const store = createTardigrade({ counter: 0, title: "draft" });
const timeline = history(store, { limit: 100 });

store.setProp("counter", 1);
store.setProp("counter", 2);

timeline.undo(); // counter: 1
timeline.undo(); // counter: 0
timeline.redo(); // counter: 1

How it works

Every step is a full picked snapshot (not a delta). Auto-record triggers on setProp, setProps and addProp; a setProps batch is recorded as one step. Resolver calls don't create steps. Content-equal snapshots are skipped, so there are no duplicated steps. A new change after undo clears the redo branch (classic behavior). When limit (default 50) is exceeded, the oldest step is dropped

Undo/redo restores the store by diff without reset(): missing keys are removed, existing ones are set, new ones are added — so dynamic props are fully supported. Since the core doesn't emit events on removeProp / reset / removeAllProps, call timeline.record() explicitly after them (or wrap bulk work in hold / unhold)

HistoryLink methods

timeline.undo();     // roll back one step, false if nothing to undo
timeline.redo();     // replay an undone step, false if nothing to redo
timeline.record();   // record current snapshot manually (after removeProp etc)

timeline.hold();     // suspend auto-record for bulk operations
timeline.unhold();   // resume auto-record and record a single step

timeline.clear();    // empty both stacks, store untouched
timeline.peek();     // current picked snapshot
timeline.peekUndo(); // snapshot the next undo() would restore, or null
timeline.peekRedo(); // snapshot the next redo() would restore, or null

timeline.canUndo;    // boolean
timeline.canRedo;    // boolean
timeline.isHeld;     // boolean

timeline.dispose();  // detach from the store, drop the stacks

Bulk operations as a single step:

timeline.hold();
store.addProp("field_email", "[email protected]");
store.addProp("field_phone", "+380");
timeline.unhold(); // one step with both fields

timeline.undo(); // both fields disappear

Merge as one logical operation:

timeline.hold();
host.merge(remote);
timeline.unhold();

After reset() start a fresh baseline:

store.reset();
timeline.clear(); // also re-attaches auto-record which reset() dropped

pick — keep ephemeral state out of history

Only picked props enter snapshots, and undo/redo touches only keys that were picked. Unpicked props (session tokens, UI state) are invisible to history and survive undo:

const timeline = history(store, {
    pick: (p) => ({ title: p.title, body: p.body }),
});

store.setProp("sidebarOpen", true); // not recorded, not restored
store.setProp("title", "New");      // recorded

History + persist together

Both layers subscribe to the same store and compose naturally:

const timeline = history(store);
const link = persist(store, { key: "editor" });

timeline.undo(); // restores props via setProp → persist auto-saves the restored state

Storage always follows the visible state, so after a reload the user continues from the last undone state. If you don't want persist restore to become an undoable step, attach persist before history (or call timeline.clear() after restore) — then the restored state is the history baseline

React

import { useHistory } from "tardigrade-store/history/react";

const Toolbar = ({ store }) => {
    const timeline = useHistory(store);

    return (
        <>
            <button disabled={!timeline.canUndo} onClick={() => timeline.undo()}>Undo</button>
            <button disabled={!timeline.canRedo} onClick={() => timeline.redo()}>Redo</button>
        </>
    );
};

The hook creates the link once per store, re-renders the component when canUndo / canRedo flip and disposes on unmount. Prop values themselves re-render through the usual bridge hooks (useTardigradeProp and others), since undo/redo restores props via regular setProp / addProp

Vue

<script setup lang="ts">
import { useHistory } from "tardigrade-store/history/vue";

const timeline = useHistory(store);
// timeline.canUndo / timeline.canRedo are reactive, safe to use in templates and computeds
</script>

<template>
    <button :disabled="!timeline.canUndo" @click="timeline.undo()">Undo</button>
    <button :disabled="!timeline.canRedo" @click="timeline.redo()">Redo</button>
</template>

The composable disposes the link when the component (or effectScope) is destroyed

Svelte

<script lang="ts">
import { tardigradeHistory } from "tardigrade-store/history/svelte";

const timeline = tardigradeHistory(store);
const { canUndo, canRedo } = timeline; // svelte-readable stores
</script>

<button disabled={!$canUndo} on:click={() => timeline.undo()}>Undo</button>
<button disabled={!$canRedo} on:click={() => timeline.redo()}>Redo</button>

Here canUndo / canRedo are readable stores ($-subscribable), the rest of the HistoryLink API is passed through unchanged. Call timeline.dispose() in onDestroy if the store outlives the component

Like persist, history itself is framework-agnostic — history(store, options) works with any bridge; the wrappers add reactive flags and lifecycle handling


Ward (write rules)

Ward is a rules layer that runs before a value lands in the store: a rule can allow the write, deny it or transform the value. It's business/semantic validation on top of the core type check — the core itself only carries a tiny extension point

import { createTardigrade } from "tardigrade-store";
import { ward } from "tardigrade-store/ward";

const store = createTardigrade({ counter: 0, email: "" });
const guard = ward(store);

guard.addRule("counter", (value) => {
    if (typeof value === "number" && value < 0) {
        return { allow: false, reason: "counter cannot be negative" };
    }
    return { allow: true };
});

guard.addRule("email", (value) => ({
    allow: true,
    value: typeof value === "string" ? value.trim().toLowerCase() : value,
}));

store.setProp("counter", -1);       // denied, value stays 0
store.setProp("email", "  [email protected] "); // stored as "[email protected]"

How it works

Rules run before core validation and before the actual write. A denied write changes nothing: prop and global listeners don't fire, persist auto-save and history auto-record don't trigger. The denial is reported through the incidents handler (with emitErrors: true it becomes a thrown error) and through the optional onDeny callback:

const guard = ward(store, {
    onDeny: (context, reason) => console.log(context, reason),
});

A rule returning { allow: true, value } replaces the value for the rest of the chain and for the write itself. The transformed value still goes through the core type check, so transforms must preserve the prop type — returning a string for a number prop gets rejected by the core right after ward

A rule that throws denies the write (fail closed), with the error message as the reason

Three kinds of rules

// global: sees every write as a context object
guard.addRule((context) => {
    // context.kind is "setProp" | "addProp" | "setProps"
    return { allow: true };
});

// kind-bound: only one operation kind
guard.addRule("addProp", (context) => {
    if (context.kind === "addProp" && !context.name.startsWith("field_")) {
        return { allow: false, reason: "dynamic props must use field_ prefix" };
    }
    return { allow: true };
});

// prop-bound shorthand: receives the value only
guard.addRule("counter", (value) => {
    return { allow: true };
});

Execution order: global rules → kind rules → prop rules; insertion order inside each group. Every rule in the chain receives the value after the transforms of the previous ones

A prop-bound rule guards the value however it enters the store: it matches both setProp and addProp with that name, so a rule can't be bypassed by removing and re-adding the prop. Kind names are reserved — a prop can't be named setProp, addProp or setProps

Batches (setProps)

A "setProps" kind rule sees the whole patch once and can allow or deny it entirely (transforming the patch isn't supported):

guard.addRule("setProps", (context) => {
    if (context.kind === "setProps" && context.patch.counter < 0) {
        return { allow: false, reason: "counter cannot be negative" };
    }
    return { allow: true };
});

Prop-bound and "setProp" kind rules still apply to every key of the batch, so per-prop validation and transforms can't be bypassed via setProps. If a per-key rule denies one key, only that key is skipped — the rest of the batch is applied and reported as a single notification

What passes through ward

setProp, addProp, setProps, and everything built on them: importProps, merge, persist restore(), history undo() / redo(). Initial props in createTardigrade(initialData) are the baseline and are never checked (the store exists before any ward does). removeProp, reset, resolvers and listeners are out of scope

For bulk operations that should skip rules, suspend the link:

guard.hold();
host.merge(remote);   // or link.restore()
guard.unhold();

WardLink methods

const id = guard.addRule(fn);        // returns symbol id
guard.removeRule(id);                // no-op if not found
guard.clearRules();                  // drop all rules, stay attached

guard.hold();                        // suspend all rules
guard.unhold();                      // resume

guard.ruleCount;                     // number
guard.isHeld;                        // boolean
guard.isDisposed;                    // boolean

guard.dispose();                     // detach from the store, writes pass freely

One active ward per store: a second ward(store) throws until the previous link is disposed. Rules don't run while a rule is being evaluated (re-entrancy guard), so a rule must not write to the same store synchronously — use transforms instead

Frameworks

Ward is framework-agnostic and needs no dedicated hooks — attach it wherever the store lives. If the store is bound to a component, tie the link to the component lifecycle:

// react
useEffect(() => {
    const guard = ward(store);
    guard.addRule("counter", counterRule);
    return () => guard.dispose();
}, [store]);
// vue (setup)
const guard = ward(store);
guard.addRule("counter", counterRule);
onScopeDispose(() => guard.dispose());
<script>
// svelte
const guard = ward(store);
guard.addRule("counter", counterRule);
onDestroy(() => guard.dispose());
</script>

For app-level stores just call ward(store) next to createTardigrade — no cleanup needed


Links

Github: fimshagal/tardigrade

E-mail: [email protected]


License

MIT


Support

If you find this package useful, consider supporting my work via PayPal:

[email protected]