amos
Amos is a decentralized state manager for React
, inspired by Redux
, Vuex
and Recoil
.
Highlights
- 😘 Decentralized, no
rootReducer
or combineReducers
, state is registered automatically - 🥰 High performance, many outstanding performance optimizations, especially with
cached selectors and transactions
- 🥳 Out of the box, no
plugins
, no middlewares
, no toolkits
, and no xxx-react
And more:
WARNING: THE API IS DESIGNING
Installation
yarn add amos
npm i -S amos
Quick start
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Box, createStore, Provider, useDispatch, useSelector, identity } from 'amos';
const countBox = new Box('count', 0, identity);
const increment = countBox.mutation((state) => state + 1);
function Count() {
const dispatch = useDispatch();
const [count] = useSelector(countBox);
const handleAdd = () => dispatch(increment());
return (
<div>
Click count: {count} <button onClick={handleAdd}>Click me</button>
</div>
);
}
const store = createStore();
ReactDOM.render(
<Provider store={store}>
<Count />
</Provider>,
document.querySelector('#root'),
);
Examples
TodoMVC
Table of Contents
Concepts
Store
A store
is an isolated world, everything will only happen in one store,
and there will be no correlation between different stores. You can create
a store
by calling createStore()
:
import { createStore } from 'amos';
const store = createStore();
The function createStore
accepts one optional parameter preloadedState
.
If your project uses server-side rendering(SSR), or has other pre-loaded
states, you can call this function like this:
const store = createStore(somePreloadedState);
This is different from redux, you don't need to pass in a reducer
, which
is the main culprit for centralizing redux. Centralization makes redux very
difficult to manage in large projects.
The store
provides two primary methods: the first one is dispatch
,
which is used to modify the state of the store
, and the other is
select
, which is used to select the state of the store
.
We need to create a simple box
and mutation
to
demonstrate this feature. Don't worry, it's just two very simple line of code,
and you can get the full picture of the box
and mutation
in the
following document.
Now, we will create the box
at first:
import { box, identity } from 'amos';
const countBox = box('count', 0, identity);
In the above code, we created a box
called countBox
, its initial state is
the number 0
. And then, we need to create a mutation
to mutate it:
import { mutation } from 'amos';
const increment = mutation(countBox, (state) => state + 1);
As you can see, we created a mutation
called increment
, it will mutate
countBox
's state, let it add 1
.
Now we begin to show how to select and mutate the state of countBox
, it
is very simple:
const originalCount = store.select(countBox);
store.dispatch(increment());
const updatedCount = store.select(countBox);
console.log([originalCount, updatedCount]));
The first line of the above code selects the state of countBox
, it should be
the initial state of countBox
, which is 0
. In the second line, we dispatched
a mutation increment
to mutate countBox
, it should add countBox
state to 1
.
So, the result of the above code will print [0, 1]
in console.
Boxes
A box
is a container to keep some metadata of a state node, such as the
key in store, and initial state of the box, etc. The box
is the key object
to associate store
and mutations
, selectors
and signals
. You can use
box()
to create a box
:
import { box, identity } from 'box';
export const countBox = box('count', 0, identity);
As you can see, you have seen these lines of code in the Store
section. We will explain this function in detail here.
The first parameter is the key
of the box
, it MUST be a string and
unique in your project's boxes, it is used internally, so it can be a string
in any format, the only thing you should care about it is KEEP IT UNIQUE.
The second one is the initial state of the box, please note it is NOT a
factory to create initial state, because the state SHOULD BE IMMUTABLE.
And the last one parameter is preload
, which is used for transform preloaded
state to its state. We need this parameter because sometimes state is different
to preloaded state. For example, if a box's state is a Immutable.js
's Record
,
and use JSON
to serialize it, you can use this method to convert it:
import { box } from 'box';
import { Record } from 'immutable';
class UserState extends Record({
firstName: '',
lastName: '',
}) {}
export const userBox = box('user', new UserState(), (preloadedState, state) => {
return state.merge(preloadedState);
});
As you can see, the preload
function accepts two parameters preloadedState
and
state
, and returns the transformed state. If your state is pure object, does not
need transform, you can use identity
to use preloadedState
directly, which is
provided by amos
as the countBox
created above.
In most cases, you only need to use the box itself, without paying attention to the
methods or properties it provides, except when you need to subscribe to signals.
Mutations
A Mutation
is an object which while mutate a box
's state when it is dispatched.
You can create a MutationFactory
by calling mutation()
:
import { mutation } from 'amos';
export const addCount = mutation(countBox, (state, delta: number) => state + delta);
const result = store.dispatch(addCount(1));
The mutation()
function accepts two parameters called box
and mutator
,
and returns a MutationFactory
. The box
is a box defined by calling box()
,
such as countBox
upon. And the mutator
is a function accepts two parameters called
state
and action
, and returns the mutated new state of the box
. The
MutationFactory
is a function accepts one optional parameter called action
and
returns a Mutation
.
The Mutation
is dispatchable, which means you can use it as the parameter of
store.dispatch()
. When it is dispatched, the following things will
happen:
- the
mutator
will be called with two parameter which are the state of the box
and
the parameter passed in when the MutationFactory
is called. - the state of the
box
will be set as the return value of the mutator
. - the return value of the
store.dispatch()
will be the parameter passed in when the
MutationFactory
is called.
In the code above, we created a MutataionFactory
called addCount
, which will add
the state of the countBox
with delta
. And dispatched addCount
with delta
equals
to 1
. The value of result
should be 1
.
Actions
An action
is an object which will do something asynchronous or synchronous, and
dispatch some Mutations
, Signals
, other Actions
when it is dispatched.
You can create an ActionFactory
by calling action()
:
import { action } from 'amos';
declare function myDBSetCount(count: number): Promise<void>;
export const addCountAsync = action((dispatch, select, delta: number) => {
const count = select(countBox);
return myDBSetCount(count + delta).then(() => dispatch(addCount(delta)));
});
const result = await store.dispatch(addCountAsync(2));
The action()
accepts one parameter called actor
, and returns an
ActionFactory
. The actor
is a function which will be called when you dispatch
the Action
. The ActionFactory
is a function, which will returns an Action
.
The Action
is dispatchable, when you dispatch it, the actor
will be called
with the following parameters: dispatch
, select
and the parameters passed in
when the ActionFactory
is called. The dispatch
and select
are the
store.dispatch
and store.select
, which are
allow you to dispatch some dispatchable things and select states. The return
value of store.dispatch(action)
is the return value of actor
.
Signals
An SignalFactory
is a function to create an Signal
, and could be subscribed by
a box
. An Signal
is an object, which while trigger the subscribed boxes
to mutate these states when you dispatch it.
You can create an SignalFactory
by calling signal()
, and subscribe its
Signals
by calling box.subscribe()
:
import { signal } from 'amos';
export const reset = signal((count: number) => ({ count }));
countBox.subscribe(reset, (state, data) => data.count);
store.select(countBox);
store.dispatch(reset(1));
The function signal()
accepts one optional parameter called creator
and
returns a SignalFactory
. The creator
is an optional function, which is called when
you call SignalFactory
, its return value will be used as the second parameter of the
subscribers and the return value of store.dispatch()
when you
dispatch it.
Please note that an signal will only be dispatched to the boxes which is invoked
already(select(box)
and dispatch(mutation)
will invoke the relative box
automatically).
Selectors
Store
provides an api called select
, which allows you to select
a box
's state or execute a selector
. For example: you can get a box's state like
this:
const count = store.select(countBox);
A selector
is a function, which accepts one parameter select
, which is the
store.select
, you can use the parameter to select a box or another
selectors
. For example:
import { Select } from 'amos';
const selectDoubleCount = (select: Select) => select(countBox) * 2;
const doubleCount = store.select(selectDoubleCount);
In this case, the selectDoubleCount
select the state of countBox
, and returns its
double value.
Sometimes, a selector
dependents on some external parameters. If you want to
provide a public selector for your box
's users, you may write some codes like this:
import { Select } from 'amos';
export const selectMultipleCount = (multiplier: number) => {
return (select: Select) => select(countBox) * multiplier;
};
const tripleCount = store.select(selectMultipleCount(3));
That seems a bit complicated, but don't worry, we provide a function
selector()
to help you create a curried selector, and it
brings more additional benefits. For example:
import { selector } from 'amos';
const selectMultipleCount = select((select, multiplier: number) => {
return select(countBox) * multiplier;
});
const tripleCount = store.select(selectMultipleCount(3));
As you can see, the selector()
function accepts one parameter
to select a value, which accepts a select
and one or more parameters which
is passed in when the returned function is called.
In addition, the selector()
accepts another parameter called
deps
, which allows you to cache the result of the selector. You can get
more information at #Selector caches.
React integration
First of all, whether you use hooks
or class components
, you need to use
<Provider />
outside these components to inject the store
to create the world. The best way is to put the <Provider />
in your root
component. For example:
import * as ReactDOM from 'react-dom';
import { createStore, Provider } from 'amos';
const store = createStore();
ReactDOM.render(
<Provider store={store}>
<MyApp />
</Provider>,
document.querySelector('#root'),
);
With react hooks
With react hooks, you can use useSelector()
to select states and use useDispatch()
to get the
store.dispatch()
to dispatch a dispatchable thing.
For example:
import { useSelector, useDispatch } from 'amos';
function Count() {
const dispatch = useDispatch();
const [count, doubleCount, trippleCount] = useSelector(
countBox,
selectDoubleCount,
selectMultipleCount(3),
);
const handleAdd = () => dispatch(increment());
return (
<div>
<span>Click count: {count}</span>
<span>Click count double: {doubleCount}</span>
<span>Click count tripple: {trippleCount}</span>
<button onClick={handleAdd}>Click me</button>
</div>
);
}
As you can see, useDispatch()
just returns the
store.dispatch
. And useSelector()
accepts multiple Selector
or Box
, it returns an array of the
parameters select result.
With class components
With class components, you can use connect()
to create
a high order component(HOC) with the state injected to the component.
connect
accepts one optional parameter called mapProps
, and returns
a function called Connector
. The mapProps
is a function that maps
the selected state to the props. The Connector
accepts a ComponentType
object, and returns a new ComponentType
.
Connect
and react-redux
's connect
are basically the same, but we
simplified some of its features. Specifically, they have these differences:
- It always inject
dispatch
to the HOC - It does not support
mapActions
- The
mapProps
's first parameter is store.select
rather than the state. - It will not copy static props from the wrapped component to the new component.
For example:
import { PureComponent } from 'react';
import { connect, ConnectedProps } from 'amos';
export interface CouuntProps extends ConnectedProps {
count: number;
}
export class Count extends PureComponent<CountProps> {
render() {
return (
<div>
<span>Click count: {this.props.count}</span>
<button onClick={() => this.props.dispatch(increment())}>Click me</button>
</div>
);
}
}
export const ConnectedCount = connect((select) => ({
count: select(countBox),
}))(Count);
Please note that connect
cannot use as decorator with typescript for the reason
of the ECMAScript specification. The following code is ok with babel
, but will
emit some types error with TypeScript
:
import { connect } from 'amos';
@connect()
class SomeComponent extends Component {
}
If you want to do this, you can create your own connector with some extra code:
import { connect, Select } from 'amos';
export function myConnect(
mapper?: (select: Select, ownedProps: unknown) => unknown,
): ClassDecorator {
return connect(mapper) as any;
}
@myConnect()
export class SomeComponent extends Component {
}
Receipts
Transactions
Every call of store.dispatch(dispatchable)
will notify all
the subscribers which is registered by store.subscribe()
.
Which means if you call store.dispatch
twice synchronously, the subscribers
will be called twice yet. Something will make the thing different:
-
call store.dispatch
with a dispatchable
array::
store.dispatch([increment(), increment()]);
-
dispatch dispatchables
in an action
synchronous:
const incrementTwice = action((dispatch) => {
dispatch(increment());
dispatch(increment());
});
store.dispatch(incrementTwice());
Both of the two action will only notice the subscribers once. Please
note the 2nd
one needs two be synchronous
, which means the
asynchronous dispatches is separated, you MUST call the dispatch()
in the 1th
form in your asynchronous actions. For example:
const exampleAction = action(async (dispatch) => {
dispatch(actionA());
dispatch(mutationB());
dispatch(signalC());
await doSomething();
dispatch(actionD());
dispatch(mutationE());
dispatch(signalF());
dispatch([actionG(), mutationH(), signalI()]);
});
Selector caches
When you use useSelector
in your component, the component
should update if the state of boxes updated. the useSelector
will caches
the last parameters and state snapshots and the result of it. if a dispatch
mutated the state which is not depended by the selectors, the component will
not rerender. It is fantastic!
Consider you have a component as follow:
import { memo } from 'react';
const MultipleCount = memo<{ multipler: number }>(({ multiper }) => {
const [count] = useSelector(selectMultipleCount(multipler));
return (
<div>
Click count * {multipler}: {count}
</div>
);
});
And it is used multiple times in a component:
function ShowCount() {
return (
<div>
<MultipleCount multipler={1} />
<MultipleCount multipler={2} />
<MultipleCount multipler={3} />
</div>
);
}
TODO
In addition, selector()
accepts the second parameter called deps
,
which is a function also. the deps
should accepts parameters same to the fn
, and
returns an array as the cache key, it will be called in useSelector
to check the
selector should or should not be rerun. For example:
const selectFactorialCount = selector(
(select) => factorial(select(countBox)),
(select) => [select(countBox)],
);
The selector will not be recomputed if the countBox
's state is not changed.
Server side rendering(SSR)
In server-side, store
provides a method store.snapshot()
allows you to get all states allocated in the store, and you can print it
to your HTML page as follow:
const store = createStore();
await store.dispatch(something);
const html = `<script>var __INITIAL_STATE__ = ${JSON.stringify(store.snapshot()).replace(
/</g,
'\\u003c',
)}</script>`;
In client side, you can read it as the preloaded state to create the store:
const store = createStore(__INITIAL_STATE__);
Hybrid with redux
Devtools
API Reference
box()
box()
is the ONLY way to create a Box
:
function box<S>(
key: string,
initialState: S,
preload: (preloadedState: JSONState<S>, state: S) => S,
): Box<S>;
interface Box<S> {
subscribe<D>(signal: SignalFactory<any[], D>, fn: (state: S, data: D) => S): void;
}
Box
is selectable
.
box.subscribe()
box.subscribe(signal, fn)
let the box mutate its state when the signal
is dispatched.
box.mutation()
box.mutation()
is the ONLY way to create a MutationFactory
:
function mutation<S, A>(
box: Box<S>,
mutator: (state: S, action: A) => S,
): (action: A) => Mutation<S, A>;
export interface Mutation<S, A> {}
Mutation
is dispatchable
.
action()
action()
is the way to create an ActionFactory
:
function action<A extends any[], R>(
actor: (dispatch: Dispatch, select: Select, ...args: A) => R,
): (...args: A) => Action<R>;
export interface Action<R> {
(dispatch: Dispatch, select: Select): R;
type?: string;
}
Action is dispatchable
.
signal()
signal()
is ONLY way to create an SignalFactory
:
function signal(): () => Signal<void>;
function signal<D>(): (data: D) => Signal<D>;
function signal<A extends any[], D>(creator: (...args: A) => D): (...args: A) => Signal<D>;
export interface Signal<D> {}
Signal is dispatchable**.
selector()
selector()
is the way to create a SelectorFactory
:
function selector<A extends any[], R>(
fn: (select: Select, ...args: A) => R,
deps?: (select: Select, ..args: A) => unknown[],
): (...args: A) => Selector<R>
export interface Selector<R> {
(select: Select): R;
}
Selector
is selectable
.
createStore()
create a store:
function createStore(
preloadedState?: Record<string, unknown>,
...enhancers: Array<(store: Store) => Store>
): Store;
interface Store {
snapshot: () => Record<string, unknown>;
dispatch: Dispatch;
select: Select;
subscribe: (fn: (mutated: Box[]) => void) => () => void;
}
store.dispatch()
store.dispatch()
dispatch one or more dispatchable
to mutate the state and
notify subscribers.
interface Dispatch {
<R>(dispatchable: Dispatchable<R>): R;
<R>(dispatchables: Dispatchalbe<R>[]): R[];
}
store.select()
store.select()
select a selectable
:
interface Select {
<R>(selectable: Selectable<R>, allocator?: [Record<string, unknown>?]): R;
}
store.snapshot()
store.snapshot()
returns the state's snapshot.
store.subscribe()
store.subscripbe(fn)
subscribes the updates.
<Provider />
<Consumer />
useSelector()
useDispatch()
useStore()
connect()
License
The MIT License (MIT)
Copyright (c) 2020 acrazing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.