NAV
typescript javascript

Introduction

Install using npm:

npm install --save small-store rxjs

Install using yarn:

yarn add small-store rxjs

Welcome to the documentation for small-store, a simple but powerful state store for javascript projects.

npm Package Version Package Dependencies Coveralls github GitHub Repo License MIT

Features:

⭐️  small in size
under 2KB in production build, under 1KB gzipped

⭐️  uses well-known store practices like actions and effects

⭐️  immutable
by using immer

⭐️  reactive
by using rxjs (as peerDependency to avoid version conflicts and keep your bundles small)

⭐️  plattform agnostic
can be used anywhere: browser, server, mobile or on the moon

⭐️  framework agnostic
can be used with anything: vanilla, react, preact, angular, vue or whatever you prefer

⭐️  native typescript support

⭐️  open source and free for everybody
licensed under MIT

Getting Started

import { Actions, Store } from 'small-store';

// state declaration
interface CounterState {
  count: number;
}

// actions
enum CounterAction {
  Increment = 'increment',
  Decrement = 'decrement'
}

// action functions
const counterActions: Actions<CounterState, CounterAction> = {
  [CounterAction.Increment]: () => state => {
    state.count++;
    return state;
  },
  [CounterAction.Decrement]: () => state => {
    state.count--;
    return state;
  }
};

// the initial state
const initialCounterState: CounterState = {
  count: 0
};

// creating the store
const counterStore = new Store<CounterState, CounterAction>(
  initialCounterState, counterActions
);

// subscribing to the store's state
counterStore.state$.subscribe(state => console.log(state));

// dispatching actions
counterStore.dispatch(CounterAction.Increment);
counterStore.dispatch(CounterAction.Increment);
counterStore.dispatch(CounterAction.Decrement);
import { Store } from 'small-store';

// action functions
const counterActions = {
  increment: () => state => {
    state.count++;
    return state;
  },
  decrement: () => state => {
    state.count--;
    return state;
  }
};

// the initial state
const initialCounterState = {
  count: 0
};

// creating the store
const counterStore = new Store(initialCounterState, counterActions);

// subscribing to the store's state
counterStore.state$.subscribe(state => console.log(state));

// dispatching actions
counterStore.dispatch('increment');
counterStore.dispatch('increment');
counterStore.dispatch('decrement');

Outputs to console:

{ count: 0 }
{ count: 1 }
{ count: 2 }
{ count: 1 }

Counter Store Example

The example code shows a simple counter store with actions to increment and decrement the count state.

Using typescript, you have to create an interface for your state and an enum for your actions to ensure type safety.

States can only be updated by dispatching actions.

An action is simply a function, receiving an optional payload (see payloads section) as param.

An action function can have two kinds of returns:

In this example, we are returning callback functions as we need to know about the current state to return the new state.

Actions are dispatched by calling Store.dispatch() with the action's name as param.

The latest store state can be retrieved by subscribing to the observable Store.state$.

Head over to the rxjs docs if you are not familiar with observables and subscriptions.

Payloads

import { Actions, Store } from 'small-store';

// state declaration
interface TodoState {
  todos: string[];
}

// actions
enum TodoAction {
  Add = 'add',
  Remove = 'remove'
}

// action payloads declaration
interface TodoActionPayloads {
  [TodoAction.Add]: { todo: string };
  [TodoAction.Remove]: { todo: string };
}

// action functions using payload
const todoActions: Actions<TodoState, TodoAction, TodoActionPayloads> = {
  [TodoAction.Add]: payload => state => {
    state.todos.push(payload.todo);
    return state;
  },
  [TodoAction.Remove]: payload => state => {
    const remKey = state.todos.findIndex(todo => todo === payload.todo);
    if (remKey >= 0) state.todos.splice(remKey, 1);
    return state;
  }
};

// the initial state
const initialTodoState: TodoState = {
  todos: []
};

// creating the store
const todoStore = new Store<TodoState, TodoAction, TodoActionPayloads>(
  initialTodoState, todoActions
);

// subscribing to the store's state
todoStore.state$.subscribe(state => console.log(state));

// dispatching actions
todoStore.dispatch(TodoAction.Add, { todo: 'eat' });
todoStore.dispatch(TodoAction.Add, { todo: 'sleep' });
todoStore.dispatch(TodoAction.Remove, { todo: 'eat' });
import { Store } from 'small-store';

// actions functions using payload
const todoActions = {
  add: payload => state => {
    state.todos.push(payload.todo);
    return state;
  },
  remove: payload => state => {
    const remKey = state.todos.findIndex(todo => todo === payload.todo);
    if (remKey >= 0) state.todos.splice(remKey, 1);
    return state;
  }
};

// the initial state
const initialTodoState = {
  todos: []
};

// creating the store
const todoStore = new Store(initialTodoState, todoActions);

// subscribing to the store's state
todoStore.state$.subscribe(state => console.log(state));

// dispatching actions
todoStore.dispatch('add', { todo: 'eat' });
todoStore.dispatch('add', { todo: 'sleep' });
todoStore.dispatch('remove', { todo: 'eat' });

Outputs in console:

{ todos: [] }
{ todos: ['eat'] }
{ todos: ['eat', 'sleep'] }
{ todos: ['sleep'] }

Todo Store Example

In this example, we create a todo store, keeping track of a collection of todos.

To add or remove a todo from the collection, our action functions are using the payload param.

Again, using typescript, you have to create an interface to declare your action payloads for type safety.

When dispatching the action via Store.dispatch(), we are using the action's name as first param and the action's payload as second param.

Selectors

import { Selectors } from 'small-store';

// todo state selectors
const todoSelectors: Selectors<TodoState> = {
  allTodos: state => state.todos,
  countTodos: state => state.todos.length,
  firstTodo: state => state.todos[0] || null
}

// subscribing to the store's state
todoStore.state$.subscribe(state => {
  // filter state with selectors and log to console
  console.log(
    'All:', todoSelectors.allTodos(state),
    '\nCount:', todoSelectors.countTodos(state),
    '\nNext:', todoSelectors.firstTodo(state)
  );
});
// todo state selectors
const todoSelectors = {
  allTodos: state => state.todos,
  countTodos: state => state.todos.length,
  firstTodo: state => state.todos[0] || null
}

// subscribing to the store's state
todoStore.state$.subscribe(state => {
  // filter state with selectors and log to console
  console.log(
    'All:', todoSelectors.allTodos(state),
    '\nCount:', todoSelectors.countTodos(state),
    '\nNext:', todoSelectors.firstTodo(state)
  );
});

Outputs in console:

All: []
Count: 0
Next: null

All: ['eat']
Count: 1
Next: 'eat'

All: ['eat', 'sleep']
Count: 2
Next: 'eat'

All: ['sleep']
Count: 1
Next: 'sleep'

Todo Store Example

Now, we are creating some selectors for the todo store we just created in the payloads section.

A selector is simply a callback, receiving the state as param and returning whatever you like to select from the state.

In this example, we're creating three selectors:

Again, we are subscribing to Store.state$, but this time we filter the output using our selectors.

Actions Stream

// only in development
if (process.env.NODE_ENV !== 'production') {
  // subscribe to the store's action stream
  todoStore.actions$.subscribe(({ name, payload, previousState, state }) =>
    // log details to console
    console.log(
      'Action:', name,
      '\nPayload:', payload,
      '\nPrevious State:', previousState,
      '\nCurrent State:', state
    )
  );
}
// only in development
if (process.env.NODE_ENV !== 'production') {
  // subscribe to the store's action stream
  todoStore.actions$.subscribe(({ name, payload, previousState, state }) =>
    // log details to console
    console.log(
      'Action:', name,
      '\nPayload:', payload,
      '\nPrevious State:', previousState,
      '\nCurrent State:', state
    )
  );
}

Outputs in console:

Action: 'add'
Payload: { todo: 'foo' }
Previous State: { todos: [] }
Current State: { todos: ['foo'] }

Action: 'add',
Payload: { todo: 'bar' },
Previous State: { todos: ['foo'] }
Current State: { todos: ['foo', 'bar'] }

Action: 'remove'
Payload: { todo: 'foo' }
Previous State: { todos: ['foo', 'bar'] }
Current State: { todos: ['bar'] }

Todo Store Example

Now, let's debug the todo store, which we created in the payloads section.

To achieve this, we simply subscribe to Store.actions$, the action stream of our store. This observable returns an object containing the name and the payload for every triggered action together with the previous state and the current state.

Effects

import { Actions, Effects, Store } from 'small-store';

// state declaration
interface WeatherState {
  location: string | null;
  weather: string | null;
}

// actions
enum WeatherAction {
  SetLocation = 'add',
  RequestWeather = 'requestWeather'
  ReceivedWeather = 'receivedWeather'
}

// action payloads declaration
interface WeatherActionPayloads {
  [WeatherAction.SetLocation]: { location: string };
  [WeatherAction.ReceivedWeather]: { weather: string };
}

// action functions (returning partial states instead of callbacks)
const weatherActions: Actions<WeatherState, WeatherAction, WeatherActionPayloads> = {
  [WeatherAction.SetLocation]: payload => {
    return { location: payload.location };
  },
  [WeatherAction.ReceivedWeather]: payload => {
    return { weather: payload.weather };
  }
};

// effects triggered by a just dispatched action
const weatherEffects: Effects<WeatherState, WeatherAction, WeatherActionPayloads> = {
  [WeatherAction.SetLocation]: (action, state, dispatch) => {
    dispatch(WeatherAction.RequestWeather);
  },
  [WeatherAction.RequestWeather]: (action, state, dispatch) => {
    fetch(`https://someweatherapi.com/location/${state.location}`)
      .then(request => request.json())
      .then(json => dispatch(WeatherAction.ReceivedWeather, { weather: json.weather }));
    );
  }
};

// the initial state
const initialWeatherState: WeatherState = {
  location: null;
  weather: null;
};

// creating the store
const weatherStore = new Store<WeatherState, WeatherAction, WeatherActionPayloads>(
  initialWeatherState, weatherActions, weatherEffects
);

// subscribing to the store's state
weatherStore.state$.subscribe(state => console.log(state));

// dispatching actions
weatherStore.dispatch(WeatherAction.SetLocation, { location: 'Berlin' });
import { Store } from 'small-store';

// action functions (returning partial states instead of callbacks)
const weatherActions = {
  setLocation: payload => {
    return { location: payload.location };
  },
  receivedWeather: payload => {
    return { weather: payload.weather };
  }
};

// effects triggered by a just dispatched action
const weatherEffects = {
  setLocation: (action, state, dispatch) => {
    dispatch('requestWeather');
  },
  requestWeather: (action, state, dispatch) => {
    fetch(`https://someweatherapi.com/location/${state.location}`)
      .then(request => request.json())
      .then(json => dispatch('receivedWeather', { weather: json.weather }));
    );
  }
};

// the initial state
const initialWeatherState = {
  location: null;
  weather: null;
};

// creating the store
const weatherStore = new Store(initialWeatherState, weatherActions, weatherEffects);

// subscribing to the store's state
weatherStore.state$.subscribe(state => console.log(state));

// dispatching actions
weatherStore.dispatch('setLocation', { location: 'Berlin' });

Outputs in console:

{ location: null, weather: null }
{ location: 'Berlin', weather: null }
{ location: 'Berlin', weather: 'Sunny, 25° Celsius' }

Weather Store Example

In this example, we create a weather store, which stores the location and the corresponding weather for the location.

Everytime the location changes, the weather should be retrieved automatically from an external weather service, which is achieved using effects.

An effect is a function that is triggered by a specific action. While actions are synchronous functions, effects can run asynchronous code and optionally dispatch other actions.

You should use effects when:

The effects object has properties where…

In this example, we have two effects:

Notice, how the requestWeather action doesn't need a corresponding function in weatherActions as it doesn't change the state and only triggers an effect.

Also notice, how the action functions are returning partial state objects instead of callbacks. As we don't need to know about the previous state for our state changes, we can use this simplification.

with React

// creating the store context
export const CounterStoreContext = createContext(initialCounterState);
// creating the store context
export const CounterStoreContext = createContext(initialCounterState);

App component:

import { Component, h } from 'react';
import { CounterStoreContext } from './counter-store';

class App extends Component {
  counterStateSubscription = counterStore.state$
    .subscribe(counterState => this.setState({ counterState }));

  componentWillUnmount() {
    this.counterStateSubscription.unsubscribe();
  }

  render() {
    return (
      <CounterStoreContext.Provider value={this.state.counterState}>
        <SomeThing />
        <OtherThing />
      </CounterStoreContext.Provider>
    );
  }
}

Some component:

import { Component, h } from 'react';
import { counterStore } from './counter-store';

export class SomeThing extends Component {
  increment() {
    counterStore.dispatch('increment');
  }
  decrement() {
    counterStore.dispatch('decrement');
  }
  render() {
    return (
      <div>
        <button onClick={this.increment}>Increment</button>
        <button onClick={this.decrement}>Decrement</button>
      </div>
    );
  }
}

Other component:

import { Component, h, useContext } from 'react';
import { CounterStoreContext } from './counter-store';

class OtherThing extends Component {
  render() {
    const counterState = useContext(CounterStoreContext);
    return <div>{counterState.count}</div>;
  }
}

Alternatively:

import { Component, h } from 'react';
import { CounterStoreContext } from './counter-store';

class OtherThing extends Component {
  render() {
    return (
      <CounterStoreContext.Consumer>
        {counterState => counterState.count}
      </CounterStoreContext.Consumer>
    );
  }
}

To use small-store with React or Preact, we simply use the frameworks' context feature.

Let's reuse the counter store, we created in the "getting started" section.

Additionally to the things we already created, we create a context for the store state using createContext.

We update the context value by simply subscribing to the state in our app component, as you can see in the example code.

Later within other components you can dispatch actions or use the context to read and use values from the store state.

with Angular

import { Injectable } from '@angular/core';
import { Store } from 'small-store';

// creating the store as injectable
@Injectable({ providedIn: 'root' })
export class CounterStore extends Store<CounterState, CounterAction> {
  constructor() {
    super(initialCounterState, counterActions);
  }
}
// you need to use typescript for angular. 😉

Some component:

import { Component } from '@angular/core';
import { CounterAction, CounterStore } from './counter-store';

@Component({
  selector: 'some-thing',
  template: `
    <button (click)="increment()">Increment</button>
    <button (click)="decrement()">Decrement</button>
  `
})
export class SomeThingComponent {
  constructor(
    public readonly counterStore: CounterStore
  ) {}

  public increment(): void {
    this.counterStore.dispatch(CounterAction.Increment);
  }

  public decrement(): void {
    this.counterStore.dispatch(CounterAction.Decrement);
  }
}
// you need to use typescript for angular. 😉

Other component:

import { Component } from '@angular/core';
import { CounterStore } from './counter-store';

@Component({
  selector: 'other-thing',
  template: `
    <div>{{ (counterStore.state$ | async).count }}</div>
  `
})
export class CounterComponent {
  constructor(
    public readonly counterStore: CounterStore
  ) {}
}
// you need to use typescript for angular. 😉

To use small-store with Angular, we simply create an injectable store.

Let's reuse the counter store, we created in the "getting started" section.

Instead of instantiating the store directly, we create a CounterStore class, which extends the Store class. By adding the @Injectable decorator, we can inject the store into other things.

Within any component or service you can now either dispatch store actions or subscribe to the state or the actions stream, as you can see it in the example code.

Feedback

Now that you have read the whole documentation, you should be able to use small-store within your project.

If you like this software, please give it a star on GitHub.

Do you have any questions or problems? Need more explanation?
Head over to GitHub and create an issue.

Have fun! 🦄