1.2.0 • Published 6 years ago

react-kiss v1.2.0

Weekly downloads
2
License
ISC
Repository
github
Last release
6 years ago

react-kiss

Redux and mobx-state-tree are both good state container solutions, however they introduces too many terminologies and bioperlates to structure a simple app.

We want something super simple and stupid, something that can define a state container everywhere at any granularity and combine them together to form a complete app. react-kiss is thus a state container which:

  • introduces minimal efforts to create and use states.
  • ships with both state and state transfer definitions, handles sync and async workflows naturally.
  • allows split states to different parts as small as possible and combine then together when required.
  • encourages establishing and joining state container in a smaller scope instead of a monotonous global state.

Install

npm install react-kiss

Region

A region is a container of a state and some workflows to manipulate state in the context of a given payload.

State

A state is a predefined structure and its current data, any plain object can be a state.

Workflow

A workflow is a process which receives a payload and manipulates current state, a workflow can manipulates state either synchronously or asychronously, it is also possible manipulates state multiple times within a workflow.

There are 2 forms of workflows.

Simple workflow

A simple workflow is a simple function that receives a payload and current state, it should return either a state patch like:

const setCurrentUser = (user, state) => {
    if (state.currentUser) {
        return {};
    }

    return {
        currentUser: user
    };
};

or a state updater function like:

const addValue = (amount, state) => {
    if (state.value >= 100) {
        return {};
    }

    return ({value}) => ({value: value + amount});
};

Composite workflow

A composite workflow is a workflow which may manipulates state multiple times or involves async process, it is defined as a generator function:

function* saveTodo(todo) {
    yield {submitting: true};
    try {
        const newTodo = yield postTodo(todo);
        yield state => {
            const {todos} = state;

            return {
                todos: [...todos, newTodo],
                submitting: false
            };
        };
    }
    catch (ex) {
        yield {submitting: false, error: ex};
    }
};

This generator function receives (payload, getState) as its arguments, and yields value in 3 types:

  • a simple object is treated as a state patch.
  • a function is treated as a state updater.
  • a Promise instance is treated as an async process, its resolved value or rejected error will returned back to yield expression.

Selector

A selector is a pure function which computes and selects certain values from current state, selectors are defined as an object with function values:

const selectors = {
    filterVisibleTodos({todos, filter}) {
        return filter ? todos.filter(todo => todo.includes(filter)) : todos;
    }
};

Selectors can also receive arbitary arguments, the first argument is always the currentState, rest arguments are those passed to selector on invocation.

When invoke a selector, the currentState argument is omitted (it is bound automatically), so the above selector is called just as const todos = filterVisibleTodos();

Define a region

To define a region, we just need to provide an initialState and a map of workflows to defineRegion exported function:

import {defineRegion} from 'react-kiss';

const initialState = {
    todos: [
        'Buy milk',
        'Meet John at peace park'
    ],
    filter: '',
    error: null,
    submitting: false
};

const workflows = {
    * saveTodo(todo) {
        yield {submitting: true};
        try {
            const newTodo = yield postTodo(todo);
            yield state => {
                const {todos} = state;

                return {
                    todos: [...todos, newTodo],
                    submitting: false
                };
            };
        }
        catch (ex) {
            yield {submitting: false, error: ex};
        }
    },

    filterByKeyword(keyword) {
        return {filter: keyword};
    }
};

const selectors = {
    filterVisibleTodos({todos, filter}) {
        return filter ? todos.filter(todo => todo.includes(filter)) : todos;
    }
};

const todoRegion = defineRegion(initialState, workflows, selectors);

export const establishTodo = todoRegion.establish;
export const joinTodo = todoRegion.join;

The return value of defineRegion function is an object containing establish and join function.

Establish a region

By defineRegion we get a region definition but it is not yet usable as a state container, we should establish it at a parent scope and join it from it's children.

To establish a region, call establish function returned by defineRegion like an HOC:

import {establishTodo} from 'regions';

const Todo = () => (
    <div>
        <Filter />
        <List />
        <AddTodo />
    </div>
);

export default establishTodo('Todo')(Todo);

The only argument of establish function is an optional name of region, by enhancing a component with establish, it now acts as a context's Provider to manage the state.

Note a region can be established in different places, just like using Provider in different places, a child receives state from the closest regions of same type.

Join a region

All children components under a component enhanced with establish can choose to join this region by invoking join function returned from defineRegion, in case a component is joined to a region, it automatically receives state and workflows from region, a mapToProps function is used to select props:

import {PureComponent} from 'react';
import {bind} from 'lodash-decorators';
import {joinTodo} from 'regions';

class AddTodo extends PureComponent {

    state = {
        todoText: ''
    };

    @bind()
    syncTodoText(e) {
        this.setState({todoText: e.target.value});
    }

    @bind()
    async saveTodo() {
        const {todoText} = this.state;
        const {onSaveTodo} = this.props;

        await onSaveTodo(todoText);
        this.setState({todoText: ''});
    }

    componentDidUpdate(prevProps) {
        if (this.props.error !== prevProps.error) {
            alert(this.props.error.message); // eslint-disable-line no-alert
        }
    }

    render() {
        const {todoText} = this.state;
        const {submitting} = this.props;

        return (
            <div>
                <input value={todoText} onChange={this.syncTodoText} />
                <button type="button" onClick={this.saveTodo} disabled={submitting}>
                    {submitting ? 'Submitting...' : 'Add Todo'}
                </button>
            </div>
        );
    }
}

const mapToProps = ({submitting, error, saveTodo}) => ({submitting, error, onSaveTodo: saveTodo});

export default joinTodo(mapToProps)(AddTodo);

This is very similar to react-redux's connect function except it only requires one mapToProps function.

Combine regions

We can establish region at any place, it is also straightforward to establish multiple regions with different types:

import {compose} from 'recompose';
import {establishTodo, establishNote} from 'regions';

const App = () => (
    // ...
);

const enhance = compose(
    establishTodo('MyTodo'),
    establishNote('Note')
);

export default ehnahce(App);

Note that it is NOT OK to establish multiple regions with the same type (returned from the same defineRegion call), in such case only the latest region takes effects.

We can also join multiple regions using the joinAll exported function:

import {compose} from 'recompose';
import {joinAll} from 'react-kiss';
import {establishNote, joinNote, joinGlobal} from 'regions';

const Note = ({username, visible, message, onToggle}) => (
    <div style={{marginTop: 20}}>
        <button type="button" onClick={onToggle}>
            {visible ? 'Hide' : 'Show'}
        </button>
        {visible && <p style={{fontSize: 48, fontWeight: 'bold', textAlign: 'center'}}>{message} @ {username}</p>}
    </div>
);

const mapToProps = (note, global) => {
    const message = note.notes[global.username];

    return {
        username: global.username,
        message: message,
        visible: note.visible,
        onToggle: note.toggle
    };
};

const enhance = compose(
    establishNote('Note'),
    joinAll(joinNote, joinGlobal, mapToProps)
);

export default enhance(Note);

The joinAll function receives multiple join functions and a mapToProps function, the mapToProps function receives all region contexts in the order join functions are given.

Transient region

In some cases we don't need a react's context to hold our state and workflows, the withTransientRegion HOC defines a region only for given component, it is a useful utility to separate state management from presetation.

import {withTransientRegion} from 'react-kiss';

const initialState = {
    value: 0
};

const workflows = {
    increment(payload, {value}) {
        return {value: value + 1};
    },

    decrement(payload, {value}) {
        return {value: value - 1};
    }
};

// The Counter component now is a pure presentational function component, state and workflows are defined in region
const Counter = ({value, increment, decrement}) => (
    <div>
        <button type="button" onClick={decrement}>dec</button>
        <span>{value}</span>
        <button type="button" onClick={increment}>inc</button>
    </div>
);

export default withTransientRegion(initialState, workflows)(Counter);

Specific regions

react-kiss also provides some predefined regions to handle common scenarios.

Query

The defineQueryRegion function accepts a request function and defines a region in such structure:

{
    queries: {
        [stringifiedParams]: {
            pendingMutex: 0, // The number of on-the-way request
            params: {}, // Requesting params
            response: {
                data: {}, // Response of success request
                error: {} // Response of fail request
            }
        },
        ...
    },
    request: function, // The workflow to trigger request
    findQuery: function(params), // Selector to find query object by params
    findReponse: function(params), // Selector to find query.response object by params
    findData: function(params) // Selector to find query.response.data object by params
}

For each invocation of request workflow, a [stringifiedParams]: Query key-value pair is stored in queries state.