Package Exports
- constate
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (constate) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
context + state = constate
Tiny React state management library that lets you work with local state and scale up to global state with ease when needed.
👓 Read the introductory article
🎮 Play with the demo
import React from "react";
import { Container } from "constate";
const initialState = { count: 0 };
const actions = {
increment: () => state => ({ count: state.count + 1 })
};
const Counter = () => (
<Container initialState={initialState} actions={actions}>
{({ count, increment }) => (
<button onClick={increment}>{count}</button>
)}
</Container>
);
Table of Contents
Installation
npm i constate
Container
In computer science, a container is a class, a data structure, or an abstract data type (ADT) whose instances are collections of other objects. In other words, they store objects in an organized way that follows specific access rules.
— https://en.wikipedia.org/wiki/Container_(abstract_data_type)
initialState
type initialState = Object;
Use this prop to define the initial state of the container.
const initialState = { count: 0 };
const Counter = () => (
<Container initialState={initialState}>
{({ count }) => <button>{count}</button>}
</Container>
);
actions
type Actions = {
[string]: () => (state: Object) => Object
};
An action is a method that returns an updater
function, which will be, internally, passed as an argument to React setState
. Actions will be exposed, then, together with state within the child function.
const initialState = { count: 0 };
const actions = {
increment: amount => state => ({ state.count + amount })
};
const Counter = () => (
<Container initialState={initialState} actions={actions}>
{({ count, increment }) => (
<button onClick={() => increment(1)}>{count}</button>
)}
</Container>
);
selectors
type Selectors = {
[string]: () => (state: Object) => any
};
A selector is a method that returns a function, which receives the current state and should return something (the thing being selected).
const initialState = { count: 0 };
const actions = {
increment: amount => state => ({ state.count + amount })
};
const selectors = {
getParity: () => state => (state.count % 2 === 0 ? "even" : "odd")
};
const Counter = () => (
<Container
initialState={initialState}
actions={actions}
selectors={selectors}
>
{({ count, increment, getParity }) => (
<button onClick={() => increment(1)}>{count} {getParity()}</button>
)}
</Container>
);
effects
type Effects = {
[string]: () => ({ state: Object, setState: Function }) => void
};
An effect is a method that returns a function, which receives both state
and setState
. This is useful if you need to perform side effects, like async actions, or just want to use setState
.
const initialState = { count: 0 };
const effects = {
tick: () => ({ setState }) => {
const fn = () => setState(state => ({ count: state.count + 1 }));
setInterval(fn, 1000);
}
};
const Counter = () => (
<Container initialState={initialState} effects={effects}>
{({ count, tick }) => (
<button onClick={tick}>{count}</button>
)}
</Container>
);
context
type Context = string;
Whenever you need to share state between components and/or feel the need to have a global state, you can pass a context
prop to Container
and wrap your app with Provider
.
import { Provider, Container } from "constate";
const CounterContainer = props => (
<Container
initialState={{ count: 0 }}
actions={{ increment: () => state => ({ count: state.count + 1 }) }}
{...props}
/>
);
const CounterButton = () => (
<CounterContainer context="counter1">
{({ increment }) => <button onClick={increment}>Increment</button>}
</CounterContainer>
);
const CounterValue = () => (
<CounterContainer context="counter1">
{({ count }) => <div>{count}</div>}
</CounterContainer>
);
const App = () => (
<Provider>
<CounterButton />
<CounterValue />
</Provider>
);
onMount
type OnMount = ({ state: Object, setState: Function }) => void;
This is a function called inside Container
's componentDidMount
.
Note: when using
context
, allContainer
s of the same context behave as a single unit, which means thatonMount
will be called only for the first mountedContainer
of each context.
const initialState = { count: 0 };
const onMount = ({ setState }) => {
const fn = () => setState(state => ({ count: state.count + 1 }));
document.body.addEventListener("mousemove", fn);
};
const Counter = () => (
<Container initialState={initialState} onMount={onMount}>
{({ count }) => <button>{count}</button>}
</Container>
);
onUpdate
type OnUpdate = ({ prevState: Object, state: Object, setState: Function }) => void;
This is a function called every time setState
is called, either internally with actions
or directly with effects
and lifecycle methods, including onUpdate
itself.
Note: when using
context
,onUpdate
will be triggered only once persetState
call no matter how manyContainer
s of the same context you have mounted.
const initialState = { count: 0 };
const onMount = ({ setState }) => {
const fn = () => setState(state => ({ count: state.count + 1 }));
setInterval(fn, 1000);
};
const onUpdate = ({ state, setState }) => {
if (state.count === 5) {
// reset counter
setState({ count: 0 });
}
};
const Counter = () => (
<Container initialState={initialState} onMount={onMount} onUpdate={onUpdate}>
{({ count }) => <button>{count}</button>}
</Container>
);
onUnmount
type OnUnmount = ({ state: Object, setState: Function }) => void;
This is a function called inside Container
's componentWillUnmount
. It receives both current state
and setState
, but the latter will have effect only if you're using context
. Otherwise, it will be noop. This is useful for making cleanups.
Note: when using
context
, allContainer
s of the same context behave as a single unit, which means thatonUnmount
will be called only when the last remainingContainer
of each context gets unmounted.
const initialState = { count: 0 };
const onMount = ({ setState }) => {
const fn = () => setState(state => ({ count: state.count + 1 }));
const interval = setInterval(fn, 1000);
setState({ interval });
};
const onUnmount = ({ state }) => {
clearInterval(state.interval);
};
const Counter = () => (
<Container initialState={initialState} onMount={onMount} onUnmount={onUnmout}>
{({ count }) => <button>{count}</button>}
</Container>
);
Provider
initialState
It's possible to pass initialState to Provider:
const initialState = {
counter1: {
count: 10
}
};
const App = () => (
<Provider initialState={initialState}>
...
</Provider>
);
This way, all Container
s with context="counter1"
will start with { count: 10 }
.
Note: when using
context
, only theinitialState
of the firstContainer
in the tree will be considered.Provider
will always take precedence overContainer
.
Composing
Since Container
is just a React component, you can create Container
s that accepts new properties, making them really composable.
For example, let's create a composable CounterContainer
:
const increment = () => state => ({ count: state.count + 1 });
const CounterContainer = ({ initialState, actions, ...props }) => (
<Container
initialState={{ count: 0, ...initialState }}
actions={{ increment, ...actions }}
{...props}
/>
);
Then, we can use it to create a DecrementableCounterContainer
:
const decrement = () => state => ({ count: state.count - 1 });
const DecrementableCounterContainer = ({ actions, ...props }) => (
<CounterContainer actions={{ decrement, ...actions }} {...props} />
);
Finally, we can use it on our other components:
const CounterButton = () => (
<DecrementableCounterContainer initialState={{ count: 10 }}>
{({ count, decrement }) => <button onClick={decrement}>{count}</button>}
</DecrementableCounterContainer>
);
Testing
actions
and selectors
are pure functions. Testing is pretty straightfoward:
import { initialState, actions, selectors } from "./CounterContainer";
test("initialState", () => {
expect(initialState).toEqual({ count: 0 });
});
test("actions", () => {
expect(actions.increment(1)({ count: 0 })).toEqual({ count: 1 });
expect(actions.increment(-1)({ count: 1 })).toEqual({ count: 0 });
});
test("selectors", () => {
expect(selectors.getParity()({ count: 0 })).toBe("even");
expect(selectors.getParity()({ count: 1 })).toBe("odd");
});
Testing effects
and lifecycle methods can be a little tricky depending on how you implement them. This is how we can test our tick
effect with Jest:
import { effects } from "./CounterContainer";
test("tick", () => {
jest.useFakeTimers();
let state = { count: 0 };
const setState = fn => {
state = fn(state);
};
effects.tick()({ state, setState });
jest.advanceTimersByTime(1000);
expect(state).toEqual({ count: 1 });
jest.advanceTimersByTime(1000);
expect(state).toEqual({ count: 2 });
});
Contributing
If you find a bug, please create an issue providing instructions to reproduce it. It's always very appreciable if you find the time to fix it. In this case, please submit a PR.
If you're a beginner, it'll be a pleasure to help you contribute. You can start by reading the beginner's guide to contributing to a GitHub project.
TODO
- Middlewares? (create an issue if you find a use case for this)
- Debugger/devtools
- Memoize selectors
License
MIT © Diego Haz