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 stringBatch 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 propsTo check was prop added use hasProp method
tardigrade.addProp("role", "customer");
console.log(tardigrade.hasProp("role")); // return trueListening to Property Changes
There are two types of listeners you can add:
- Property-Specific Listeners: These listen to changes on a specific property
- 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 listenersOnce a property is removed, any attempt to update it will result in an error:
tardigrade.setProp("username", "newUser"); // Throws an error since 'username' was removedYou 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 storeimportProps 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 importMerge 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 mergingAs 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 importCore 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 stateReset
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 errorDynamic 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 aboveIncome 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 trueUsage 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 depsBy 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 injectThe 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 resultSemantics 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 laterHow 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 workingBulk operations without extra writes:
link.hold();
store.addProp("a", 1);
store.removeProp("old");
link.unhold(); // single save with the final stateMigrations
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 $-syntaxThe 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: 1How 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 stacksBulk 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 disappearMerge 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() droppedpick — 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"); // recordedHistory + 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 stateStorage 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 freelyOne 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:
