Features
- Familiar
Inspired by Immer and Redux - Composable
Attach/Detach action handlers as needed. Components can bring their own handlers! - Immutability
Modifications or mutations within a handler do not affect state until completion - Full TypeScript Support
Utilize TypeScript for in-action guards and validations - Extremely lightweight
Weighs only 623 bytes, including dependencies!
Install
$ npm install --save vegemite
Usage
Check out the
/examples
directory for more patterns.
import vegemite from 'vegemite';
const todomvc = vegemite({
next: 1,
todos: [],
});
// handler-type: mutate
todomvc.on('todo:add', (state, data) => {
let nxt = state.next++;
state.todos.push({
id: nxt,
done: false,
title: data.title,
});
});
// handler-type: return object
todomvc.on('todo:toggle', (state, id) => {
let todos = state.todos.map(item => {
if (item.id === id) item.done = !item.done;
return item;
});
return { next: state.next, todos };
});
// handler-type: return Promise
todomvc.on('todo:assign', async (state, data) => {
await httpie.send('POST', `/api/todos/${data.id}/assign`, {
body: { user: data.userid }
});
state.todos = state.todos.map(item => {
if (item.id === data.id) {
item.assigned = item.assigned || [];
item.assigned.push(data.userid);
}
return item;
});
});
// Add a global "listener" (side effect)
// This runs _after_ state has been updated
todomvc.listen((state, prev) => {
console.log('~> this is the NEW state:', state);
console.log('~> this was the OLD state:', prev);
});
API
vegemite(state?)
Returns Vegemite
Returns a Vegemite instance, pre-filled with an initial state object.
state
Type: State extends any
Any initial data of your choosing.
Vegemite.state
Returns: State
A getter function that returns a snapshot of the current state.
Vegemite.on(event, handler)
Returns: Function
Assign a new action handler for a specific event.
Returns an unsubscriber function that, when called, will detach this handler
.
Important: When more than one
handler
exists for an event, they are executed in the order that they were assigned.
let store = vegemite({ count: 5 });
let detach = store.on('count:add', (state, event) => {
state.count += event.value;
});
await store.dispatch('count:add', { value: 10 });
console.log(store.state); //=> { count: 15 }
detach(); // deactivated
await store.dispatch('count:add', { value: 3 });
console.log(store.state); //=> { count: 15 } (unchanged)
event
Type: String
The event/topic name.
handler
Type: Handler
See Handlers below.
Vegemite.listen(event, subscriber)
Vegemite.listen(subscriber)Returns: Function
Attach a new subscription — when no event
is specified, then the subscriber
will listen to all events.
Returns an unsubscriber function that, when called, will detach this subscriber
.
Important: When more than one
subscriber
exists for an event (or globally), they are executed in the order that they were assigned.
let store = vegemite({ count: 5 });
store.on('increment', state => {
state.count++;
});
let unlisten = store.listen('increment', (state, prevState) => {
console.log(`heard increment: ${prevState.count} ~> ${state.count}`);
});
await store.dispatch('increment');
//=> heard increment: 5 ~> 6
unlisten(); // deactivated
await store.dispatch('increment');
// (silence)
event
Type: String
The event/topic name, if any.
subscriber
Type: Subscriber
See Subscribers below.
Vegemite.set(state, event?)
Returns: void
Forcibly set the instance's state.
When set()
is run, all related subscribers/listeners are called.
Optionally pass an event
to trigger a specific set of listeners.
Note: This is automatically called at the end of a
dispatch()
chain.
state
Type: State
The new State
data.
event
Type: String
The event/topic on whose behalf you're committing.
Vegemite.dispatch(event, eventData)
Returns: Promise<void>
Sends a new message to an event/topic.
When dispatch()
is called, all related action handlers (via on()
) are run to produce a new state. Once this chain is complete, set()
is invoked with the new state, which will trigger any listeners for the event
(in addition to global listeners).
Attention TypeScript Users:
For events that do not requireeventData
, you must dispatch with anundefined
payload, or similar.
event
Type: String
The event/topic to target.
eventData
Type: any
Any data to send the topic.
Important: Your
eventData
is sent to your event/topic's handlers as is!
Handlers
Action handlers can be thought of as "producers" or "reducers" – they are functions that, when matched, are invoked to help create a new state.
Action handlers belong to a specific event and are assigned via on()
. Multiple handlers may exist for a single event topic. When this is the case, the list of handlers will be executed in the order that they were assigned. This allows handlers to access previous handlers' results and use those as part of its own computation, if desired.
Every handler receives the current state
as its first parameter and then any eventData
that was sent to the event (via dispatch()
). Handlers may return a new state
object or they may choose to mutate the state
in place and forgo a return entirely. A Promise
may also be returned that resolves either to a new state or to a mutation.
type Handler<T, X> = (state: T, eventData: X) => Promise<T | void> | T | void;
While an event's handlers are running (dubbed the "dispatch chain"), you are free to mutate the state
and eventData
object as much as you'd like — mutations have no effect on the instance's actual state during this time! Once the "dispatch chain" has resolved (aka, all handlers have completed), Vegemite will promote the chain's final state
object as the instance's new actual state (via set(state, event)
).
At this point, the instance's state has been updated, which means ctx.state
will reflect the current changes. Additionally, any subscribers (via listen()
) are enqueued for action.
Subscribers
Subscribers, or listeners, run after the instance has updated its internal state. These can be thought of as "callbacks" or side-effects that, when matched, should be alerted about a change in state.
All subscribers receive the current state
as its first parameter and the previous state as its second parameter. You can use this information to infer what, specifically, changed between the two state objects. The return value from subscribers has no effect anywhere.
type Listener<T> = (state: T, prevState: T) => any;
Subscribers can listen in on a specific event topic, or they can listen to everything (aka, "global" listeners).
When an event's "dispatch chain" has resolved, any subscribers for that event will be queued in the order that they were assigned. Additionally, all "global" subscribers, if any, will be executed in the order that they were defined. However, please note that global subscribers always execute before event-specific subscribers.
TypeScript Support
Vegemite is made to work with an "EventMap" — this is an interface whose keys are the names of your events and whose values are the event-datas you expect to pass along as the message. With this information alone, TypeScript and vegemite
can ensure that all of your event publishers and subscribers are passing or expecting the correct data types.
Additionally, vegemite
requires a State
descriptor — this is a separate interface that describes what you want this vegemite
instance to contain at all times. With this information, TypeScript can ensure your actions/handlers are abiding by the rules & not causing mischief.
interface Todo {
id: number;
done: boolean;
title: string;
assigned?: string[];
}
interface Assignment {
id: Todo['id'];
userid: string;
}
interface EventMap {
'todo:add': Todo['title'];
'todo:toggle': Todo['id'];
'todo:assign': Assignment;
// ...
}
interface State {
next: number;
todos: Todo[];
}
const todomvc = vegemite<EventMap, State>({
next: 1,
todos: []
});
With the setup above, TypeScript can help us ensure that the todo:add
and todo:toggle
topics will only send or receive their respective Todo
attributes, whereas the todo:assign
topic will always send or receive a more complex Assignment
object.
Meanwhile, any manipulations within the State
will be protected, ensuring that state.todos
only contains valid Todo
objects and state.next
is always a number.
License
MIT © Luke Edwards