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.
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:
Another callback function, receiving the current state as param and returning the complete new state.
An object which is either a partial of the new state, which will be merged with the previous state, or a complete new state (see effects section).
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:
allTodos
will return all todos in our collectioncountTodos
will return the amount of the todos in our collectionnextTodo
will return the first todo from the collection
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:
- You need to react on an action.
- You need to run asynchronous or expensive code.
The effects object has properties where…
- the key is the name of the action which triggers the effect
- the value is a function, receiving the action name and payload, the current state and the dispatch function to trigger new actions.
In this example, we have two effects:
- on the
setLocation
action, therequestWeather
action is dispatched with the new location state as payload. - on the
requestWeather
action, the external weather service is called and when the weather request is done, thereceivedWeather
action is called with the received weather status as payload.
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! 🦄