@micra/store-hooks v0.0.7
@micra/store-hooks
Installation
yarn add @micra/store-hooks
Basic usage
import { state } from "@micra/store-hooks";
const counter = state<number>(10);
const double = computed(counter, (count) => count * 2);
const increment = mutator(counter, (count, by: number = 1) => count + by);
const decrement = mutator(counter, (count, by: number = 1) => count - by);
counter.value; // 10
double.value; // 20
increment(10); // counter.value = 20 / double.value = 40
decrement(20); // counter.value = 0 / double.value = 0
Using a framework
React
import React from 'react';
import { state } from '@micra/store-hooks';
import { useStore } from '@micra/react-store-hooks';
const counter = state(0);
const Counter = () => {
const [count, setCount] = useStore(counter);
return (
<div>
<h1>Count is {count}</h1>
<button onClick={() => setCount(count + 1)}>increment</button>
<button onClick={() => setCount(count - 1)}>decrement</button>
</div>
);
};
Vue
<template>
<div id="app">
<h1>Count is {{ count }}</h1>
<button @click="increment">increment</button>
<button @click="decrement">decrement</button>
</div>
</template>
<script>
import { useStore } from "@micra/vue-store-hooks";
import { state } from "@micra/store-hooks";
export const counter = state(0);
export default {
setup() {
const [count, setCount] = useStore(counter);
return {
count,
increment: () => setCount(count => count + 1),
decrement: () => setCount(count => count - 1)
};
}
};
</script>
Note:
@micra/vue-store-hooks
uses Vue's composition API which is still in beta.
Alpine
See @micra/alpine-store-hooks:
import 'alpinejs';
import { initStores } from '@micra/alpine-store-hooks';
const count = state(0);
const increment = mutator(counter, (count, by: number = 1) => count + by);
const decrement = mutator(counter, (count, by: number = 1) => count - by);
window.$stores = initStores({
counter: {
count,
increment,
decrement
}
});
<div
x-data="{ increment: $stores.counter.increment, decrement: $stores.counter.decrement }"
x-store="counter.count"
>
<h1>Count is <span x-text="$count"></span></h1>
<button @click="increment">increment</button>
<button @click="decrement">decrement</button>
</div>
Svelte
<script>
import { state, mutator } from "@micra/store-hooks";
const counter = state(0);
const increment = mutator(counter, count => count + 1);
const decrement = mutator(counter, count => count + 1);
</script>
<h1>Count is {$counter || counter.value}</h1>
<button on:click={increment}>increment</button>
<button on:click={decrement}>decrement</button>
Devtools
The store hooks can be hooked up to Redux Devtools!
See @micra/store-hooks-devtools:
import { state } from '@micra/store-hooks';
import { devtools } from '@micra/store-hooks-devtools';
export const counter = state<number>(0);
devtools({
counter,
// ... other states
});
Hooks
state
The state hook is the heart of the store hooks! It sets the foundation for all the other rest of the lib to work. This hooks observes a given value and sends updates to subscribers whenever the value is updated. This can store any value T
you want.
Defining state
import { state } from "@micra/store-hooks";
// Simple value
export const counter = state<number>(0);
// Object
export interface LoginForm { email: string; password: string }
export const loginForm = state<LoginForm>({
email: '',
password: '',
});
// Array
export interface Todo { task: string; complete: boolean }
export const todoList = state<Todo[]>([
{ task: 'Write documentation', complete: false },
]);
Reading state
To read the value of the state, you can read the value
param. So, based on the examples above:
import { counter, loginForm, todoList } from "./states";
counter.value // 0
loginForm.value // { email: '', password: '' }
todoList.value // [ { task: 'Write documentation', complete: false } ]
Updating state
There are four ways to update a given state:
- Setting the value
- Using set with a value
- Using set with a callback
- Using a mutator (see section below)
1. Setting the value
The quickest way to update a state is to simply assign a new value to the value
param:
import { counter, loginForm, todoList } from "./states";
counter.value = 42;
loginForm.value = { email: 'john@example.com', password: 'P@s5W0rD' }
todoList.value = [
{ task: 'Fix bugs', complete: false }
];
This will then synchronize the update with the subscribers.
It's worth noting that for state values which are reference types (e.g. objects and arrays), mutating the object will not trigger syncs. This means that:
// BAD! Won't sync
todoList.value.push({ task: 'Respond to issues', complete: false });
// Good! Will sync
todoList.value = todoList.value.concat({ task: 'Respond to issues', complete: false });
2. Using set with a value
Another way to update the state is by using the set
method. This method allows you to call it in two different ways:
- passing the new value as argument
- passing a callback that returns the new value (see number 3 below)
For simple cases, passing the value is a great option:
import { counter } from "./states";
counter.set(12);
When you're dealing with objects, this strategy is particularly handy as you don't need to pass the full object structure. Internally, the hook will merge it with the current state:
import { loginForm } from "./states";
// Update all fields
loginForm.set({ email: 'jane@example.com', password: 'My_P@s5W0rD' });
// Update only password... not very safely
loginForm.set({ password: '1234567890a' });
3. Using set with a callback
As we saw above, the set
method also accepts a callback function. This function receives the current state as argument and should return the new state or, if you're handling an object, a partial version of the state:
import { counter, loginForm, todoList } from "./states";
counter.set((count) => count * 2));
loginForm.set((data) => { email: data.email, password: '1234567890a' });
todoList.set((list) => list.concat([{ task: 'Publish next version', complete: false }]));
4. Using a mutator (see section below)
This method will have it's own little section below. I think you'll like it. =)
Validating state
Every once and a while, there are some states that have some limitations. For instance, a counter that can't have negative values or a form field that can't be empty. For cases like these, we can pass a validator function as the second parameter of the state hook. This function takes the shape of:
(currentState: T, updates: U) => boolean;
If the return is true
, then it will run the update. If false
, the update will be blocked.
Let's see some examples:
import { state } from "@micra/store-hooks";
// A counter that can't have negative values
export const counter = state<number>(0, (_, newCount) => newCount > 0);
// A counter that can't set a value lower than the current
export const counter = state<number>(0, (prevCount, newCount) => newCount > prevCount);
// A login form where e-mail can't be empty
export interface LoginForm { email: string; password: string }
export const loginForm = state<LoginForm>(
{ email: '', password: '' },
(_, updates) => {
const fields = Object.keys(updates);
if (fields.includes('email') && !Boolean(updates.email)) {
return false;
}
return true;
}
);
Resetting to the initial state
Sometimes it's useful to reset a given state to it's initial state. For example, say that after submitting a form you need to clear the data or you wish to clear a counter. For that, states have the reset
method!
import { counter, loginForm } from "./states";
// Clearing our counter
counter.reset(); // back to 0!
// Imaginary login form submission
const submitLoginForm = () => {
const data = loginForm.value;
// login logic...
loginForm.reset(); // back to { email: '', password: '' }
}
Subscribing for changes
To listen to updates on a given state, you can use the subscribe
method and pass a callback. This function will receive the new state as argument and return an unsubscribe
function.
export interface State<T> {
// ...
subscribe<U = T>(
listener: StateListener<U>,
selector?: StateSelector<T, U>,
shouldUpdate?: Compare<U, U>,
): () => void;
// ...
}
So, if we want to get notified whenever the counter value changes, then we can do:
import { counter } from "./states";
counter.subscribe((count) => {
console.log(count);
});
What if I need just part of the state?
Very good question! If you need to modify the state in some way, the subscribe function accepts a second argument. This argument is a callback which will receive the updated state as an argument and should return the transformed value. Let's see a couple of examples:
import { counter, loginForm, todoList } from "./states";
/**
* Getting the double value of the counter
*/
counter.subscribe(
(double) => {
console.log(double); // <~~ will receive the double of the current count
},
(count) => count * 2,
);
/**
* Getting only the e-mail from the login form
*/
loginForm.subscribe<string>(
(email) => {
console.log(email); // <~~ will be the e-mail
},
({ email }) => email,
);
/**
* Getting only the completed to-dos from the list
*/
todoList.subscribe(
(complete) => {
console.log(complete); // <~~ will contain only the complete to-dos
},
(list) => list.filter(todo => todo.complete),
);
If you like this, go take a look at the
computed
hook! I think you're gonna love it ;)
Very nice! But, what if I don't want to receive all the updates?
Another very good question! If you're dealing with components, you might not want to receive all the updates to avoid unnecessary re-renders. You can certainly do that by passing a third argument to the subscribe function. This function receives the previous state as the first argument, the updates on the second and should return a boolean. If this function returns true
, the subscription will be triggered. If false
, then the updated will be ignored.
type Compare<T, U = any> = (prevState: T, update: U) => boolean;
Let's see an example:
import { loginForm } from "./states";
/**
* Since we only need the e-mail, we only want to get notified when that changes.
*/
loginForm.subscribe<string>(
(email) => {
console.log(email); // <~~ will only be called if there are changes to the e-mail.
},
({ email }) => email,
(prevState, updates) => prevState.email !== updates.email,
);
Unsubscribing for changes
After subscribing to a given state, we sometimes need to stop listening to updates. To do that you can simply call the function that's returned by the subscribe
function. let's see!
import { counter } from "./states";
const unsubscribe = counter.subscribe((count) => {
console.log(count);
});
unsubscribe(); // <~~~~ this will cancel your subscription!
Clearing all subscription from the state
If you need to unsubscribe all listeners from a state, you can use the the flush
method. This will make sure the no-one is listening for the next updates.
import { counter } from "./states";
counter.flush();
Lifecycle events
Lifecycle events are asynchronous events emitted by the state. These will be called in the next tick so that observers can get notified when something happens. To listen for these events use the on
method which accepts a lifecycle event, a listener and returns an unsubscribe function.
update
Called when all subscriptions are synced.
on(event: 'update', listener: (previousState: T, nextState: T) => void): () => void;
cancel
Called when an update event is cancelled.
on(event: 'cancel', listener: (state: T, cancelledState: T) => void): () => void;
error
Called when an error occurs while synchronizing a subscription.
on(event: 'error', listener: (error: Error, currentState: T, nextState: T) => void): () => void;
flush
Called when all subscriptions are cleared from the state.
on(event: 'flush', listener: (state: T) => void): () => void;
State object
export interface ValueState<T = any> {
value: T;
set(partial: Partial<T> | ((state: T) => Partial<T>)): T;
reset(): T;
flush(): void;
on(event: Lifecycle, listener: (...payload?: any[]) => void): void;
subscribe<U = T>(
listener: StateListener<U>,
selector?: StateSelector<T, U>,
shouldUpdate?: Compare<U, U>,
): () => void;
}
computed
When going over the the state
hook, we talked about how we can use the subscribe
to transform the current state. While that's very cool, it can get kinda clumsy to keep track of the transformed value. To make this easier and cleaner, we can use the computed
hook!
This hook receives a state as the first argument, the selector as the second and the should update function as the last (similar to the subscribe
function).
Let's take that to-do list example:
import { state, computed } from "@micra/store-hooks";
// Simple value
export const counter = state<number>(0);
export const double = computed(counter, (count) => count * 2);
// Array
export interface Todo { task: string; complete: boolean }
export const todoList = state<Todo[]>([]);
export const completed = computed(todoList, (list) => list.filter(todo => todo.complete));
mutator
The last hook we have is the mutator
. When talking about the state
hook, we mentioned that one of the ways to modify the state was using mutators.
import { state, mutator } from "@micra/store-hooks";
// Incrementing/decrementing a counter
export const counter = state<number>(0);
export const increment = mutator(counter, (count, by: number = 1) => count + by);
export const decrement = mutator(counter, (count, by: number = 1) => count - by);
increment(10); // counter.value = 10;
decrement(20); // counter.value = -10;
// Managing a to-do list
export interface Todo { task: string; complete: boolean }
export const todoList = state<Todo[]>([]);
export const addTodo = mutator(todoList, (list, todo: Todo) => list.concat([todo]));
export const clearComplete = mutator(todoList, (list) => list.filter(todo => !todo.complete));
export const completeTodo = mutator(todoList, (list, task: string) =>
list.map(todo => todo.task === task ? { ...todo, complete: true } : todo)
);
addTodo({ task: 'Write documentation', complete: false }); // [{ task: 'Write documentation', complete: false }]
completeTodo('Write documentation'); // [{ task: 'Write documentation', complete: true }]
clearComplete(); // []