amos
TypeScript icon, indicating that this package has built-in type declarations

0.2.12 • Public • Published

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

# or var npm
npm i -S amos

Quick start

Edit Amos count

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

Edit Amos - 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 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 will 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 parameters 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 return 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 allow you to dispatch some dispatchable things and select states. The return value of store.dispatch(action) is the return value of actor.

Signals

A SignalFactory is a function to create an Signal, and could be subscribed by a box. A Signal is an object, which will trigger the subscribed boxes to mutate these states when you dispatch it.

You can create a 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 a 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 = selector((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:

  1. It always injects dispatch to the HOC.
  2. It does not support mapActions.
  3. The mapProps's first parameter is store.select rather than the state.
  4. 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 type errors 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 are 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:

  1. call store.dispatch with a dispatchable array:

    store.dispatch([increment(), increment()]);
  2. dispatch dispatchables in an action synchronous:

    const incrementTwice = action((dispatch) => {
      dispatch(increment());
      dispatch(increment());
    });
    
    store.dispatch(incrementTwice());

Both of the two actions will only notice the subscribers once. Please note the 2nd one needs to be synchronous, which means the asynchronous dispatches are 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());
  // the upon three dispatches are in one transaction

  await doSomething();
  dispatch(actionD());
  dispatch(mutationE());
  dispatch(signalF());
  // the upon three dispatches are three separate transaction

  dispatch([actionG(), mutationH(), signalI()]);
  // the upon dispatch will dispatch the three dispatchable in a transaction
});

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 if the selector should 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.

Dependencies (1)

Dev Dependencies (24)

Package Sidebar

Install

npm i amos

Weekly Downloads

448

Version

0.2.12

License

MIT

Unpacked Size

286 kB

Total Files

39

Last publish

Collaborators

  • acrazing