@sampettersson/constate
v0.8.2
Published
Yet another React state management library that lets you work with local state and scale up to global state with ease
Downloads
3
Readme
context + state = constate
React state management library built with scalability in mind. You can start simple 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 = {
[key: string]: (...args: any[]) =>
((state: object) => 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.
You can also return the object directly if you don't need state
.
const initialState = { count: 0 };
const actions = {
increment: amount => state => ({ count: state.count + amount })
};
const Counter = () => (
<Container initialState={initialState} actions={actions}>
{({ count, increment }) => (
<button onClick={() => increment(1)}>{count}</button>
)}
</Container>
);
selectors
type Selectors = {
[key: string]: (...args: any[]) =>
(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 => ({ count: 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 = {
[key: string]: (...args: any[]) =>
(props: { 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 = (props: {
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 = (props: {
prevState: object,
state: object,
setState: Function,
type: string
}) => 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.
Besides prevState
, state
and setState
, it receives a type
property, which can be either the name of the action
, effect
or one of the lifecycle methods that triggered it, 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, type }) => {
if (type === "onMount" && 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 = (props: {
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={onUnmount}>
{({ count }) => <button>{count}</button>}
</Container>
);
shouldUpdate
type ShouldUpdate = (props: {
state: object,
nextState: object
}) => boolean;
This is a function called inside Container
s shouldComponentUpdate
. It receives the current state
and nextState
and should return true
or false
. If it returns false
, onUpdate
won't be called for that change, and it won't trigger another render.
In the previous example using onUnmount
, we stored the result of setInterval
in the state. That's ok to do, but the downside is that it would trigger an additional render, even though our UI didn't depend on state.interval
. We can use shouldUpdate
to ignore state.interval
, for example:
const initialState = { count: 0, updates: 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 onUpdate = ({ type, setState }) => {
// prevent infinite loop
if (type !== "onUpdate") {
setState(state => ({ updates: state.updates + 1 }));
}
};
// Don't call onUpdate and render if `interval` has changed
const shouldUpdate = ({ state, nextState }) =>
state.interval === nextState.interval;
const Counter = () => (
<Container
initialState={initialState}
onMount={onMount}
onUnmount={onUnmount}
onUpdate={onUpdate}
shouldUpdate={shouldUpdate}
>
{({ count, updates }) => (
<Button>
Count: {count}
<br />
Updates: {updates}
</Button>
)}
</Container>
);
Provider
You should wrap your app with Provider
if you want to use context
.
initialState
type InitialState = object;
It's possible to pass initialState to Provider. In the example below, 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
.
const initialState = {
counter1: {
count: 10
}
};
const App = () => (
<Provider initialState={initialState}>
...
</Provider>
);
onMount
type OnMount = (props: {
state: object,
setContextState: Function
}) => void;
As well as with Container
, you can pass an onMount
prop to Provider
. The function will be called when Provider
's componentDidMount
gets called.
const onMount = ({ setContextState }) => {
setContextState("counter1", { count: 0 });
};
const MyProvider = props => (
<Provider onMount={onMount} {...props} />
);
const App = () => (
<MyProvider>
...
</MyProvider>
);
onUpdate
type OnUpdate = (props: {
prevState: object,
state: object,
setContextState: Function,
context: string,
type: string
}) => void;
onUpdate
will be called every time Provider
's setState
gets called. If setContextState
was called instead, onUpdate
will also receive a context
prop.
Container
s, when the context
prop is defined, use setContextState
internally, which means that Provider
's onUpdate
will be triggered for every change on the context.
const initialState = { counter1: { incrementCalls: 0 } };
const onUpdate = ({ context, type, setContextState }) => {
if (type === "increment") {
setContextState(context, state => ({
incrementCalls: state.incrementCalls + 1
}));
}
};
const MyProvider = props => (
<Provider initialState={initialState} onUpdate={onUpdate} {...props} />
);
const CounterContainer = props => (
<Container
initialState={{ count: 0 }}
actions={{ increment: () => state => ({ count: state.count + 2 }) }}
{...props}
/>
);
const Counter = () => (
<MyProvider>
<CounterContainer context="counter1">
{({ count, incrementCalls, increment }) => (
<button onClick={increment}>
count: {count}<br />
incrementCalls: {incrementCalls}
</button>
)}
</CounterContainer>
</MyProvider>
);
onUnmount
type OnUnmount = (props: { state: object }) => void;
onUnmount
will be triggered in Provider
's componentWillUnmount
.
const onUnmount = ({ state }) => {
console.log(state);
};
const App = () => (
<Provider onUnmount={onUnmount}>
...
</Provider>
);
devtools
type Devtools = boolean;
Passing devtools
prop to Provider
will enable redux-devtools-extension integration, if that's installed on your browser. With that, you can easily debug the state of your application.
Note: It only works for context state. If you want to debug local state, add a
context
prop toContainer
temporarily.
const App = () => (
<Provider devtools>
...
</Provider>
);
TypeScript
Constate is written in TypeScript and exports many useful types to help you. When creating a new container, you can start by defining its public API. That is, the props that are passed to the children function:
interface State {
count: number;
}
interface Actions {
increment: (amount?: number) => void;
}
interface Selectors {
getParity: () => "even" | "odd";
}
interface Effects {
tick: () => void;
}
In computer programming, it's a good practice to define the API before actually implementing it. This way, you're explicitly saying how the container should be consumed. Then, you can use handful interfaces exported by Constate to create your container:
import { ActionMap, SelectorMap, EffectMap } from "constate";
const initialState: State = {
count: 0
};
const actions: ActionMap<State, Actions> = {
increment: (amount = 1) => state => ({ count: state.count + amount })
};
const selectors: SelectorMap<State, Selectors> = {
getParity: () => state => (state.count % 2 === 0 ? "even" : "odd")
};
const effects: EffectsMap<State, Effects> = {
tick: () => ({ setState }) => {
const fn = () => setState(state => ({ count: state.count + 1 }));
setInterval(fn, 1000);
}
}
Those interfaces (e.g. ActionMap
) will create a map using your State
and your public API (e.g. Actions
).
If you're using VSCode or other code editor that supports TypeScript, you'll probably have a great developer experience. Tooling will infer types and give you autocomplete for most things:
Then, you just need to pass those maps to Container
:
const Counter = () => (
<Container
initialState={initialState}
actions={actions}
selectors={selectors}
effects={effects}
>
{({ count, increment, getParity, tick }) => ...}
</Container>
);
It'll also provide autocomplete and hints on the public API:
If you're building a composable container - that is, a component without children
that receives props -, you can define your component as a ComposableContainer
:
import { Container, ComposableContainer } from "constate";
const CounterContainer: ComposableContainer<State, Actions, Selectors, Effects> = props => (
<Container
{...props}
initialState={{ ...initialState, ...props.initialState }}
actions={actions}
selectors={selectors}
effects={effects}
/>
);
Then, you can use it in other parts of your application and still take advantage from typings. ComposableContainer
will handle them for you:
const Counter = () => (
<CounterContainer initialState={{ count: 10 }} context="counter1">
{({ count, increment, getParity, tick }) => ...}
</CounterContainer>
);
ComposableContainer
doesn't accept otheractions
,selectors
andeffects
as props. That's because, as of today, there's no way for TypeScript to dynamically merge props and infer their types correctly.
There're also useful interfaces for lifecycle methods. You can find them all in src/types.ts
.
Testing
actions
and selectors
are pure functions and you can test them directly:
test("increment", () => {
expect(increment(1)({ count: 0 })).toEqual({ count: 1 });
expect(increment(-1)({ count: 1 })).toEqual({ count: 0 });
});
test("getParity", () => {
expect(getParity()({ count: 0 })).toBe("even");
expect(getParity()({ count: 1 })).toBe("odd");
});
On the other hand, effects
and lifecycle methods can be a little tricky to test depending on how you implement them.
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.
Run npm start
to run examples.
License
MIT © Diego Haz