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
[object Object]
- 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.
[object Object]
- 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.
[object Object]
- 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 observable stream or BehaviorSubject. For example, a TaskBoardViewModel can create a small store to hold statusFilter, expose store.actions for filter buttons, and replay the latest setting into an RxJS BehaviorSubject that drives the 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 } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { counterStore } from './store';
@Injectable({ providedIn: 'root' })
export class CounterService implements OnDestroy {
private state$ = new BehaviorSubject(counterStore.getState());
private unsubscribe: (() => void) | null = null;
constructor() {
this.unsubscribe = counterStore.subscribe((newState) => {
this.state$.next(newState);
});
}
get state() {
return this.state$.asObservable();
}
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,
}