A simple, efficient, and robust reactive data store to help write decoupled code.
It doubles as a learning resource through the provided samples you can run from the GitHub demos or your local clone.
A typical case is in frontend development between UI and data dependencies: UI components describe what slice of data they care about, the store notifies them when that slice changes, keeping the logic encapsulated on each component.
- Updates only the UI affected by a state change.
- No virtual DOM or browser dependency, can be used too for UI-less scenarios.
- Allows UI subscribers to use existing DOM nodes instead of recreating the component HTML.
- Batches consecutive updates on the next frame draw, falling back to a microtask while the page is hidden (browsers pause
requestAnimationFramethere) — a pass already queued when the page hides is flushed immediately rather than stalling until it's shown again. - Subscribers always receive a settled state.
- Works via
importor<script>. - No runtime dependencies.
- Core store logic is about 100 lines of code (≈700 B minified and gzipped).
It's meant to stay small, for simple and medium-complexity scenarios. For larger-scale frameworks with similar store concepts and more features see Solid, Preact signals, Vue etc.
Used in production by tutorforme.org.
Fun fact: I first implemented this pattern in 1997 in C++ for "Microsoft Money" reactive UI updates from database changes. It was the first Microsoft program using reactive UI!
The store implementation lives in src/store.js with an ES module wrapper in src/store.module.js. The public API is:
Including src/store.js with a plain <script> tag makes the API available as StoreLib.createStore(...). In module-aware environments use import { createStore } from './src/store.module.js';.
const store = createStore(initialState, options?); //returns an object with `get`, `set`, `patch`, and `subscribe`. `options.scheduler` is optional — see Custom scheduling.
store.get(); //returns the latest snapshot of state.
store.set(newState); //replaces the state and queues notifications.
store.patch(partialState); //shallow merges state and queues notifications.
store.subscribe(callback, selector?); //registers a listener. The callback runs immediately with the initial selected value and only runs again when that value changes.Internally, tinyReactive keeps a Set of subscribers. Each subscriber caches the last value from its selector, so updates fire only when needed. Notifications batch on the next requestAnimationFrame while the page is visible, so all mutations settle before DOM work; while the page is hidden — a background tab, a minimized window — rAF is paused by the browser, so tinyReactive batches on a microtask instead (queueMicrotask, or setTimeout outside the browser), and flushes a pass that was already queued when the page hides rather than leaving subscribers stale until it's shown again. This matters for consumers that stay visible while the page that owns the store is hidden — a Document Picture-in-Picture window, a popup, a mirror. Selector or subscriber failures are caught, logged, and unsubscribed so a single bug cannot stall the store or provide an inconsistent state.
You can inspect and debug samples directly from the demos in GitHub Pages, or clone the repo:
git clone https://github.com/zmandel/tinyreactive.git
cd tinyreactiveThe project is framework-free; open the minimal demo from your file explorer at samples/minimal/index.html directly in your browser (file:// protocol)
For richer examples such as samples/tasks-app, run a local dev server (import modules need http).
cd samples/tasks-app
npm install
npm run devImport the store library (import or a <script>) and wire it to your UI code:
<div id="count"></div>
<button>Increment</button>
<script type="module">
import { createStore } from './src/store.module.js';
// Create a store with a primitive initial value.
// Note: for complex states, use objects such as { count: 0, otherProp: true }.
const store = createStore(0);
// Subscribe to the entire store, since its just one value.
// Note: for complex states, use selectors to subscribe to slices: state => state.count (see tasks-app sample)
store.subscribe(value => {
document.querySelector('#count').textContent = value;
});
document.querySelector('button').addEventListener('click', () => {
store.set(store.get() + 1);
});
</script>In this case, the state is just a number primitive. When its an object, it detects changes with an internal shallow object comparison of the selector slice, using the included helper valuesEqual(a,b).
By default tinyReactive picks the scheduler for you (see Basics): requestAnimationFrame while the page is visible, a microtask while it's hidden. Most consumers never need to think about this.
A consumer that renders somewhere other than the page holding the store — most commonly a Document Picture-in-Picture window mirroring a dashboard tab — can pass its own scheduler so notifications are timed against its paint cycle instead:
const pipWindow = await documentPictureInPicture.requestWindow({});
const store = createStore(initialState, {
scheduler: (flush) => pipWindow.requestAnimationFrame(flush),
});scheduler is called with a flush function whenever a notification pass is pending; call flush to deliver it. flush is idempotent per pending pass — calling it more than once before the next mutation is a no-op — so it's safe to invoke from more than one place. Providing options.scheduler opts the store out of the built-in visibility handling entirely: the caller owns timing completely, including while its own page is hidden. A non-function scheduler throws TypeError at createStore time.
Note: page visibility (document.visibilityState) does not capture Chrome's occlusion throttling, where a window is covered by another but not formally hidden — requestAnimationFrame can still slow down there with no portable signal to react to. The hidden-tab case — the one where rAF fully stops — is what the built-in scheduling and options.scheduler both address.
These samples can be run and debugged directly from the demos below.
samples/minimalwires a counter to UI. The subscription renders the count, and the click handler only sets the new count.samples/tasks-appView store events from a notification panel (👁️) as state changes propagate to the UI. Independent subscriptions render the lists, summary, filter buttons, and notification panel. Selectors such asstate => state.todoskeep updates targeted to only what changed.
Inspect the running examples directly:
Open the devtools, set breakpoints, and watch how state changes travel through selectors into the UI.
Issues and pull requests are welcome.
This project is licensed under the MIT License.
