Web Loom logoWeb.loom
Published PackagesStore Core

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-core

Basic 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. The updater function 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: When true, setState automatically persists the state after every change.
  • merge: When true, 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.localStorage using 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: LocalStorageAdapter for small settings, IndexedDBAdapter for large/offline datasets, MemoryAdapter for testing.
  • Invalidate or reset persisted state when switching contexts (logout, user switch).
  • Wrap manual refetch or async actions in try/catch and let the store state surface errors.

Common Patterns

  • Global App State: Export centralized stores from store/app.ts and 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

  1. Rely on shallow comparisons—store-core skips notifying subscribers when state references are stable.
  2. Subscribe only to stores you actually read to limit updates.
  3. Use memoized selectors for computed values that depend on multiple pieces of state.
  4. Batch related updates inside a single set call 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,
}

See Also

Was this helpful?
Web Loom logoWeb.loom
Copyright © Web Loom. All rights reserved.