Store Core
A minimal client state management library for building reactive web applications.
Store Core
@web-loom/store-core is a minimal, framework-agnostic client-side state management library designed for building reactive web applications. It provides a simple and efficient way to manage UI state with a focus on type safety and predictability.
Overview
Inspired by patterns from libraries like Redux and Zustand, store-core offers a createStore function to define a reactive store. This store encapsulates your application's state, actions to modify that state, and mechanisms to subscribe to state changes.
The library is built with TypeScript and emphasizes:
- Simplicity: An intuitive API with minimal boilerplate.
- Type Safety: Full TypeScript support for robust type checking and autocompletion.
- Framework Agnostic: Usable with any JavaScript framework (React, Vue, Angular, Svelte) or vanilla JavaScript.
- Predictable State Changes: State modifications occur only through explicit actions, promoting immutability.
- Lightweight: Small bundle size and high performance.
Core Concepts
- Store: A single source of truth for a specific part of your application's state. It contains the state, actions, and subscription logic.
- State: A plain JavaScript object representing the current data.
- Actions: Functions defined within the store that encapsulate the logic for modifying the state. All state changes must go through actions.
- Selectors: Functions that derive computed data from the state.
- Listeners: Functions that are called whenever the state in the store changes, allowing UI components or other parts of the application to react.
Installation
npm install @web-loom/store-coreBasic Usage
import { createStore } from '@web-loom/store-core';
// 1. Define your state interface
interface CounterState {
count: number;
}
// 2. Define your actions interface
interface CounterActions {
increment: () => void;
decrement: () => void;
add: (amount: number) => void;
}
// 3. Create the store
const store = createStore<CounterState, CounterActions>(
{ count: 0 }, // Initial state
(set, get, actions) => ({
increment: () => set((state) => ({ ...state, count: state.count + 1 })),
decrement: () => set((state) => ({ ...state, count: state.count - 1 })),
add: (amount: number) => set((state) => ({ ...state, count: state.count + amount })),
}),
);
// 4. Get current state
console.log(store.getState()); // { count: 0 }
// 5. Dispatch actions
store.actions.increment();
console.log(store.getState()); // { count: 1 }
store.actions.add(5);
console.log(store.getState()); // { count: 6 }
// 6. Subscribe to state changes
const unsubscribe = store.subscribe((newState, oldState) => {
console.log('State changed:', oldState, '->', newState);
});
store.actions.decrement();
// Output: State changed: { count: 6 } -> { count: 5 }
console.log(store.getState()); // { count: 5 }
// 7. Unsubscribe when no longer needed
unsubscribe();
// 8. Destroy the store to clean up listeners (e.g., when a component unmounts)
store.destroy();API
createStore<S, A>(initialState, createActions)
initialState: S: The initial state object.createActions: (set, get, actions) => A: A function that receives:set: (updater: (state: S) => S) => void: A function to update the state. Theupdaterfunction receives the current state and should return a new state object (immutability is key).get: () => S: A function to get the current state. Useful for actions that need to read the state before updating.actions: A: A reference to the actions object itself, allowing actions to call other actions. This function must return an object containing your action implementations.
Returns a Store instance.
Store<S, A> instance
getState(): S: Returns the current state.setState(updater: (state: S) => S): void: Updates the state. Primarily for internal use by actions.subscribe(listener: (newState: S, oldState: S) => void): () => void: Subscribes a listener to state changes. Returns an unsubscribe function.destroy(): void: Clears all listeners. Call this to prevent memory leaks when the store is no longer needed.actions: A: An object containing the actions you defined.
Persistence
Passing a persistence configuration to createStore lets you keep UI state across reloads or share it between tabs.
import { createStore, LocalStorageAdapter } from '@web-loom/store-core';
const preferencesStore = createStore(
defaultPreferences,
(set) => ({
setTheme: (theme) => set((state) => ({ ...state, theme })),
toggleNotifications: () => set((state) => ({ ...state, notifications: !state.notifications })),
}),
{
adapter: new LocalStorageAdapter(),
key: 'task-flow-ui-preferences',
autoSync: true,
merge: true,
},
);Persistence Configuration
interface PersistenceConfig<S> {
adapter: PersistenceAdapter<S>;
key: string;
autoSync?: boolean; // defaults to true
merge?: boolean; // defaults to false
serialize?: (state: S) => string;
deserialize?: (data: string) => S;
}adapter: One of the built-in adapters (or a custom implementation) that reads/writes the serialized state.key: Storage key used by the adapter to scope your store's data.autoSync: Whentrue,setStateautomatically persists the state after every change.merge: Whentrue, hydrations merge with the incoming state instead of replacing it.serialize/deserialize: Override the default JSON serializer when needed (e.g., for dates or typed data).
Built-in Adapters
LocalStorageAdapter
- Persists state into
window.localStorageusing JSON serialization. - ~5–10 MB quota per origin; be mindful of size and catch quota errors.
- Synchronous API, so writes happen immediately.
import { LocalStorageAdapter } from '@web-loom/store-core';
const store = createStore(
initialState,
createActions,
{
adapter: new LocalStorageAdapter(),
key: 'session-preferences',
autoSync: true,
},
);Use this adapter for lightweight UI preferences, auth flags, or small datasets that need to survive refreshes.
IndexedDBAdapter
- Stores data in IndexedDB for larger datasets or offline applications.
- Asynchronous API (
Promise-based) with higher quotas (~50 MB+). - Ideal for complex data structures that would exceed
localStorage.
import { IndexedDBAdapter } from '@web-loom/store-core';
const store = createStore(
initialState,
createActions,
{
adapter: new IndexedDBAdapter('my-documents-db'),
key: 'documents',
autoSync: true,
},
);Tip: Give each app a unique database name, handle quota rejections, and periodically clean outdated records.
MemoryAdapter
- In-memory persistence useful for tests or ephemeral state.
- Mirrors the API of other adapters but keeps data only in RAM.
import { MemoryAdapter } from '@web-loom/store-core';
const memoryAdapter = new MemoryAdapter<MyState>();
const store = createStore(
initialState,
createActions,
{
adapter: memoryAdapter,
key: 'test-store',
},
);
memoryAdapter.clear();Persisted Store Methods
When persistence is enabled, the returned store supports a few extra helpers:
persist(): Promise<void>– manually flush the current state to the adapter.hydrate(): Promise<void>– reload state from storage (bypasses auto sync).clearPersisted(): Promise<void>– erase stored state for this key.
Use these when you need fine-grained control (e.g., when restoring from a backup or resetting the UI between users).
Pairing with MVVM Core
Use store-core for UI state that should live beside your MVVM Core view models rather than inside a single Model signal. For example, a TaskBoardViewModel can create a small store to hold statusFilter, expose store.actions for filter buttons, and mirror the latest setting into a @web-loom/signals-core signal() that drives a computed() task-filtering pipeline.
Avoid mixing business data and UI state inside the same store. MVVM Core view models already provide data$, isLoading$, and the domain mutations you need. Layer a store-core store on top when you need to remember which tab is active, whether a drawer is open, or the selected filter in a table, and feed that UI state back into your view model logic via store.subscribe or store.getState().
Framework Integration
store-core ships with no framework dependencies, but you can easily integrate it with React, Vue, or Angular by subscribing to the store and re-rendering when the state changes.
React
import { useEffect, useState } from 'react';
import type { Store } from '@web-loom/store-core';
export function useStore<S, A>(store: Store<S, A>): [S, A] {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe((newState) => {
setState(newState);
});
return unsubscribe;
}, [store]);
return [state, store.actions];
}
function Counter() {
const [state, actions] = useStore(counterStore);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={actions.increment}>+</button>
<button onClick={actions.decrement}>-</button>
</div>
);
}Vue 3
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { counterStore } from './store';
const state = ref(counterStore.getState());
onMounted(() => {
const unsubscribe = counterStore.subscribe((newState) => {
state.value = newState;
});
onUnmounted(unsubscribe);
});
const actions = counterStore.actions;
</script>
<template>
<div>
<p>Count: {{ state.count }}</p>
<button @click="actions.increment">+</button>
<button @click="actions.decrement">-</button>
</div>
</template>Angular
import { Injectable, OnDestroy, signal } from '@angular/core';
import { counterStore } from './store';
@Injectable({ providedIn: 'root' })
export class CounterService implements OnDestroy {
private _state = signal(counterStore.getState());
private unsubscribe: (() => void) | null = null;
constructor() {
this.unsubscribe = counterStore.subscribe((newState) => {
this._state.set(newState);
});
}
get state() {
return this._state.asReadonly();
}
get actions() {
return counterStore.actions;
}
ngOnDestroy() {
this.unsubscribe?.();
}
}Advanced Patterns
Async Actions
Use async/await within actions to coordinate API calls while manually toggling loading and error flags.
const apiStore = createStore<ApiState, ApiActions>(
{ data: null, loading: false, error: null },
(set, get) => ({
fetchUsers: async () => {
set((state) => ({ ...state, loading: true, error: null }));
try {
const response = await fetch('/api/users');
const users = await response.json();
set((state) => ({ ...state, data: users, loading: false }));
} catch (error) {
set((state) => ({ ...state, loading: false, error: error.message }));
}
},
clearError: () => set((state) => ({ ...state, error: null })),
})
);Selectors (Computed Values)
Selectors derive reusable, memoized data from the current state without re-subscribing to the store.
const todoSelectors = {
getVisibleTodos: (state: TodoState) => {
const { todos, filter } = state;
if (filter === 'active') return todos.filter((t) => !t.completed);
if (filter === 'completed') return todos.filter((t) => t.completed);
return todos;
},
getTodoStats: (state: TodoState) => ({
total: state.todos.length,
completed: state.todos.filter((t) => t.completed).length,
active: state.todos.filter((t) => !t.completed).length,
}),
};Middleware Pattern
Wrap stores with middleware to extend behavior (logging, time travel, auditing).
function createLogger<S, A>(storeName: string) {
return (store: Store<S, A>) => {
const originalSubscribe = store.subscribe;
store.subscribe = (listener) => {
return originalSubscribe((newState, oldState) => {
console.group(`🔄 ${storeName}`);
console.log('Previous:', oldState);
console.log('Next:', newState);
console.groupEnd();
listener(newState, oldState);
});
};
return store;
};
}
const enhancedStore = createLogger('Counter')(createStore(initialState, createActions));Time Travel (Undo/Redo)
Time travel stores stash history internally and expose undo, redo, and helper flags.
function createTimeTravel<S, A>() {
const history: S[] = [];
let currentIndex = -1;
return (store: Store<S, A>) => {
const originalSetState = store.setState;
store.setState = (updater) => {
const newState = updater(store.getState());
history.splice(currentIndex + 1);
history.push(newState);
currentIndex = history.length - 1;
originalSetState(updater);
};
store.undo = () => {
if (currentIndex > 0) {
currentIndex--;
originalSetState(() => history[currentIndex]);
}
};
store.redo = () => {
if (currentIndex < history.length - 1) {
currentIndex++;
originalSetState(() => history[currentIndex]);
}
};
store.canUndo = () => currentIndex > 0;
store.canRedo = () => currentIndex < history.length - 1;
history.push(store.getState());
currentIndex = 0;
return store;
};
}TypeScript Support
store-core exports a rich type system so your stores remain type-safe.
import type {
Store,
PersistedStore,
Listener,
Actions,
State,
PersistenceAdapter,
PersistenceConfig,
} from '@web-loom/store-core';Define states and actions explicitly to unlock IDE autocompletion and compiler checks for every set, get, and action call.
Best Practices
- Keep stores focused on a single domain (e.g., user, cart, UI).
- Always return new state objects; never mutate the incoming state in
set. - Unsubscribe when components are destroyed to avoid memory leaks.
- Use selectors to encapsulate derived/computed data.
- Handle loading/error flags when performing async actions.
- Choose the right persistence adapter:
LocalStorageAdapterfor small settings,IndexedDBAdapterfor large/offline datasets,MemoryAdapterfor testing. - Invalidate or reset persisted state when switching contexts (logout, user switch).
- Wrap manual
refetchor async actions intry/catchand let the store state surface errors.
Common Patterns
- Global App State: Export centralized stores from
store/app.tsand import them wherever needed. - Module-Scoped Stores: Keep feature stores (cart, user, ui) near their components to reduce coupling.
- Composition: Aggregate multiple stores in a root object (
const rootStore = { user: userStore, cart: cartStore }).
Performance Tips
- Rely on shallow comparisons—
store-coreskips notifying subscribers when state references are stable. - Subscribe only to stores you actually read to limit updates.
- Use memoized selectors for computed values that depend on multiple pieces of state.
- Batch related updates inside a single
setcall when changing multiple fields.
Troubleshooting
UI Doesn’t Update
Ensure your action returns a new state object instead of mutating the existing one:
set((state) => ({ ...state, count: state.count + 1 }));Memory Leaks
Always clean up subscriptions in effects/components/services:
useEffect(() => {
const unsubscribe = store.subscribe(handleChange);
return unsubscribe;
}, []);Persistence Not Working
Double-check adapter configuration:
{
adapter: new LocalStorageAdapter(),
key: 'my-app-state',
autoSync: true,
}