easy-peasy
Version:
Easy peasy global state for React
1,685 lines (1,240 loc) • 63.3 kB
Markdown
>
> We are baking up the next version (v2.4.0). You can see all the details within the [PR](https://github.com/ctrlplusb/easy-peasy/pull/181) and view the updated docs [here](https://github.com/ctrlplusb/easy-peasy/tree/next).
>
<p> </p>
<p align='center'>
<img src="https://i.imgur.com/F6WfRFI.png" width="130" />
</p>
<p align='center'>Easy peasy global state for React</p>
<p> </p>
[](http://npm.im/easy-peasy)
[](http://opensource.org/licenses/MIT)
[](https://travis-ci.org/ctrlplusb/easy-peasy)
[](https://codecov.io/github/ctrlplusb/easy-peasy)
```javascript
import { action, createStore, StoreProvider, useStore, useActions } from 'easy-peasy';
const store = createStore({
todos: {
items: ['Install easy-peasy', 'Define your model', 'Have fun'],
add: action((state, payload) => {
state.items.push(payload)
})
}
});
const App = () => (
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
function TodoList() {
const todos = useStore(state => state.todos.items)
const add = useActions(actions => actions.todos.add)
return (
<div>
{todos.map((todo, idx) => <div key={idx}>{todo}</div>)}
<AddTodo onAdd={add} />
</div>
)
}
```
<details>
<summary>View the above code snippet with comments</summary>
<p>
```javascript
import { action, createStore, StoreProvider, useStore, useActions } from 'easy-peasy';
// 👇 create your store, providing the model
const store = createStore({
todos: {
items: ['Install easy-peasy', 'Define your model', 'Have fun'],
// 👇 define actions directly on your model
add: action((state, payload) => {
// simply mutate state to update, and we convert to immutable updates
state.items.push(payload)
// (you can also return a new immutable version of state if you prefer)
})
}
});
const App = () => (
// 👇 wrap your app to expose the store
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
function TodoList() {
// 👇 use hooks to get state or actions
const todos = useStore(state => state.todos.items)
const add = useActions(actions => actions.todos.add)
return (
<div>
{todos.map((todo, idx) => <div key={idx}>{todo}</div>)}
<AddTodo onAdd={add} />
</div>
)
}
```
</p>
</details>
## Highlights
- Wraps Redux, all the radness, without the boilerplate
- Intuitive API allowing rapid development
- Mutate state, we do the hard work for you, auto converting mutations to immutable updates
- Thunks for data fetching and side effects
- React Hook based API
- Testing helpers baked in
- Supports Typescript (ships with definitions)
- Supports React Native
- Compatible with most of the Redux ecosystem:
- Redux Dev Tools support out of the box
- Store customisation, e.g. middleware
<p> </p>
<p align='center'>
<img src='https://i.imgur.com/2vFSy1y.png' width='500' />
</p>
<p> </p>
## TOCs
- [Introduction](#introduction)
- [Installation](#installation)
- [Examples](#examples)
- [Easy Peasy Typescript](#easy-peasy-typescript)
- [React Todo List](#react-todo-list)
- [Tutorial](#tutorial)
- [Core Concepts](#core-concepts)
- [Creating the store](#creating-the-store)
- [Accessing state directly via the store](#accessing-state-directly-via-the-store)
- [Modifying state via actions](#modifying-state-via-actions)
- [Dispatching actions directly via the store](#dispatching-actions-directly-via-the-store)
- [Creating a `thunk` action](#creating-a-thunk-action)
- [Dispatching a `thunk` action directly via the store](#dispatching-a-thunk-action-directly-via-the-store)
- [Deriving state via `select`](#deriving-state-via-select)
- [Accessing Derived State directly via the store](#accessing-derived-state-directly-via-the-store)
- [Updating multiple parts of your state in response to a thunk/action](#updating-multiple-parts-of-your-state-in-response-to-a-thunkaction)
- [Usage with React](#usage-with-react)
- [Wrap your app with StoreProvider](#wrap-your-app-with-storeprovider)
- [Accessing state in your Components](#accessing-state-in-your-components)
- [Dispatching actions in your Components](#dispatching-actions-in-your-components)
- [Usage via react-redux](#usage-via-react-redux)
- [API](#api)
- [createStore(model, config)](#createstoremodel-config)
- [action](#action)
- [thunk(action)](#thunkaction)
- [reducer(fn)](#reducerfn)
- [select(selector)](#selectselector)
- [listen(on)](#listenon)
- [StoreProvider](#storeprovider)
- [useStore(mapState, externals)](#usestoremapstate-externals)
- [useActions(mapActions)](#useactionsmapactions)
- [useDispatch()](#usedispatch)
- [Usage with Typescript](#usage-with-typescript)
- [Usage with React Native](#usage-with-react-native)
- [Writing Tests](#writing-tests)
- [Typescript API](#typescript-api)
- [Tips and Tricks](#tips-and-tricks)
- [Generalising effects/actions/state via helpers](#generalising-effectsactionsstate-via-helpers)
- [Prior Art](#prior-art)
<p> </p>
---
## Introduction
Easy Peasy gives you the power of Redux (and its tooling) whilst avoiding the boilerplate. It allows you to create a Redux store by defining a model that describes your state and its actions. Batteries are included - you don't need to configure any additional packages to support derived state, side effects, memoisation, or integration with React.
<p> </p>
---
## Installation
Firstly, install React and React DOM.
```bash
npm install react
npm install react-dom
```
> Note: please ensure you install versions >= 16.8.0 for both `react` and `react-dom`, as this library depends on the new hooks feature
Then install Easy Peasy.
```bash
npm install easy-peasy
```
You're off to the races.
<p> </p>
---
## Examples
### Easy Peasy Typescript
This GitHub repository shows off how to utilise Typescript with Easy Peasy. I highly recommend cloning it and running it so that you can experience first hand what a joy it is to have types helping you with global state.
https://github.com/ctrlplusb/easy-peasy-typescript
### React Todo List
A simple implementation of a todo list that utilises a mock service to illustrate data fetching/persisting via effect actions. A fully stateful app with no class components. Hot dang hooks are awesome.
https://codesandbox.io/s/woyn8xqk15
<p> </p>
---
## Tutorial
The below will introduce you to the core concepts of Easy Peasy, where we will interact with the Redux store directly. In a following section we shall illustrate how to integrate [Easy Peasy within a React application](#usage-with-react).
### Core Concepts
#### Creating the store
Firstly you need to define your model. This represents the structure of your Redux state along with its default values. Your model can be as deep and complex as you like. Feel free to split your model across many files, importing and composing them as you like.
```javascript
const model = {
todos: {
items: [],
}
};
```
Then you provide your model to `createStore`.
```javascript
import { createStore } from 'easy-peasy';
const store = createStore(model);
```
You will now have a [Redux store](https://redux.js.org/api/store) - all the standard APIs of a Redux store is available to you. 👍
#### Accessing state directly via the store
You can access your store's state using the `getState` API of the Redux store.
```javascript
store.getState().todos.items;
```
#### Modifying state via actions
In order to mutate your state you need to define an action against your model.
```javascript
import { action } from 'easy-peasy'; // 👈 import the helper
const store = createStore({
todos: {
items: [],
// 👇 define the action with the helper
addTodo: action((state, payload) => {
// Mutate the state directly. Under the hood we convert this to an
// an immutable update.
state.items.push(payload)
})
}
});
```
The action will receive as its first parameter the slice of the state that it was added to. So in the example above our action would receive `{ items: [] }` as the value for its `state` argument. It will also receive any `payload` that may have been provided when the action was triggered.
> Note: Some prefer not to use a mutation based API. You can alternatively return new instances of your state:
>
> ```javascript
> addTodo: action((state, payload) => {
> return { ...state, items: [...state.items, payload] };
> })
> ```
>
> Personally I find the above harder to read and more prone to error.
#### Dispatching actions directly via the store
Easy Peasy will bind your actions against the stores `dispatch`. They will be bound using paths that match the location of the action on your model.
```javascript
// |-- payload
// |------------------|
store.dispatch.todos.addTodo('Install easy-peasy');
// |-------------|
// |-- path matches our model (todos.addTodo)
```
Call `getState` to see the updated state.
```javascript
store.getState().todos.items;
// ['Install easy-peasy']
```
#### Creating a `thunk` action
If you wish to perform side effects, such as fetching or persisting data from your server then you can use the `thunk` helper to declare a thunk action.
```javascript
import { thunk } from 'easy-peasy'; // 👈 import the helper
const store = createStore({
todos: {
items: [],
// 👇 define a thunk action via the helper
saveTodo: thunk(async (actions, payload) => {
// 👆
// Notice that the thunk will receive the actions allowing you to dispatch
// other actions after you have performed your side effect.
const saved = await todoService.save(payload);
// Now we dispatch an action to add the saved item to our state
// 👇
actions.todoSaved(saved);
}),
todoSaved: action((state, payload) => {
state.items.push(payload)
})
}
});
```
You cannot modify the state within a `thunk`, however, the `thunk` is provided the `actions` that are local to it. This allows you to delegate state updates via your actions (an experience similar to that of `redux-thunk`).
#### Dispatching a `thunk` action directly via the store
You can dispatch a thunk action in the same manner as a normal action. A `thunk` action always returns a `Promise` allowing you to execute any process after the `thunk` has completed.
```javascript
store.dispatch.todos.saveTodo('Install easy-peasy').then(() => {
console.log('Todo saved');
})
```
#### Deriving state via `select`
If you have state that can be derived from state then you can use the [`select`](#selectselector) helper. Simply attach it to any part of your model.
```javascript
import { select } from 'easy-peasy'; // 👈 import the helper
const store = createStore({
shoppingBasket: {
products: [{ name: 'Shoes', price: 123 }, { name: 'Hat', price: 75 }],
totalPrice: select(state =>
state.products.reduce((acc, cur) => acc + cur.price, 0)
)
}
}
```
The derived data will be cached and will only be recalculated when the associated state changes.
This can be really helpful to avoid unnecessary re-renders in your react components, especially when you do things like converting an object map to an array in your `connect`. Typically people would use [`reselect`](https://github.com/reduxjs/reselect) to alleviate this issue, however, with Easy Peasy this feature is baked right in.
> Note: we don't recommend attaching selectors to the root of your store, as those will be executed for _every_ change to your store. If you absolutely need to, try to attach as few selectors to the root as you can.
#### Accessing Derived State directly via the store
You can access derived state as though it were a normal piece of state.
```javascript
store.getState().shoppingBasket.totalPrice
```
> Note! See how we don't call the derived state as a function. You access it as a simple property.
#### Updating multiple parts of your state in response to a thunk/action
When firing an action you may want multiple parts of your model to respond to it. For example, you may want to clear certain parts of your state when a user logs out. Or perhaps you want an audit log that tracks specific events.
Easy Peasy provides you with the `listen` helper to do this.
```javascript
import { listen } from 'easy-peasy'; // 👈 import the helper
const todosModel = {
items: [],
// 👇 the action we wish to respond to / track
addTodo: action((state, payload) => {
state.items.push(payload)
})
};
const auditModel = {
logs: [],
// 👇 declare listeners via the helper
listeners: listen((on) => {
on(
// 👇 pass in the reference to the action we wish to listen to
todosModel.addTodo,
// 👇 the action we wish to execute after `addTodo` has completed
action((state, payload) => {
state.logs.push(`Added a new todo: ${payload}`);
})
);
})
};
const model = {
todos: todosModel,
audit: auditModel
};
```
This is a more advanced feature, however, using this method allows a clearer seperation of concerns and promotes reactive programming.
You can do more than this with the `listen` helper. You can listen to an `action` or a `thunk`, and can execute either an `action` or a `thunk` in response. Please read the [docs](#listenon) for more information.
### Usage with React
We will now cover how to integrate your store with your React components. We leverage [Hooks](https://reactjs.org/docs/hooks-intro.html) to do so. If you aren't familiar with hooks yet we highly recommend that you read the [official documentation](https://reactjs.org/docs/hooks-intro.html) and try playing with our [examples](#examples).
> If you want to be able to use your Easy Peasy store with Class components then you can utilise the `react-redux` package - this is covered further down in the tutorial.
> If you haven't done so already we highly recommend that you install the [Redux Dev Tools Extension](https://github.com/zalmoxisus/redux-devtools-extension). This will allow you to visualise your actions firing along with the associated state updates.
#### Wrap your app with StoreProvider
Firstly you need to create your store then wrap your application with the `StoreProvider`.
```javascript
import { StoreProvider, createStore } from 'easy-peasy';
import model from './model'
const store = createStore(model);
const App = () => (
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
```
#### Accessing state in your Components
To access state within your components you can use the `useStore` hook.
```javascript
import { useStore } from 'easy-peasy';
const TodoList = () => {
const todos = useStore(state => state.todos.items);
return (
<div>
{todos.map((todo, idx) => <div key={idx}>{todo.text}</div>)}
</div>
);
};
```
In the case that your `useStore` implementation depends on an "external" value when mapping state, you should provide the respective "external" within the second argument to the `useStore`. This is a similar requirement to some of the official hooks that are bundled with React. The `useStore` hook will then track the external value and ensure that the state is remapped every time the external value(s) change.
```javascript
import { useStore } from 'easy-peasy';
const Product = ({ id }) => {
const product = useStore(
state => state.products[id], // 👈 we are using an external value: "id"
[id] // 👈 we provide "id" so our useStore knows to re-execute mapState
// if the "id" value changes
);
return (
<div>
<h1>{product.title}</h1>
<p>{product.description}</p>
</div>
);
};
```
We recommend that you read the API docs for the [`useStore` hook](#usestoremapstate-externals) to gain a full understanding of the behaviours and pitfalls of the hook.
#### Dispatching actions in your Components
In order to fire actions in your components you can use the `useActions` hook.
```javascript
import { useState } from 'react';
import { useActions } from 'easy-peasy';
const AddTodo = () => {
const [text, setText] = useState('');
const addTodo = useActions(actions => actions.todos.add);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => addTodo(text)}>Add</button>
</div>
);
};
```
For more on how you can use this hook please ready the API docs for the [`useActions` hook](#useactionsmapactions).
#### Usage via react-redux
As Easy Peasy outputs a standard Redux store it is entirely possible to use Easy Peasy with the official [`react-redux`](https://github.com/reduxjs/react-redux) package.
This allows you to do a few things:
- Slowly migrate a legacy application that is built using `react-redux`
- Connect your store to Class components via `connect`
<details>
<summary>First, install the `react-redux` package</summary>
<p>
```bash
npm install react-redux
```
</p>
</details>
<details>
<summary>Then wrap your app with the `Provider`</summary>
<p>
```javascript
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'easy-peasy';
import { Provider } from 'react-redux'; // 👈 import the provider
import model from './model';
import TodoList from './components/TodoList';
// 👇 then create your store
const store = createStore(model);
const App = () => (
// 👇 then pass it to the Provider
<Provider store={store}>
<TodoList />
</Provider>
)
render(<App />, document.querySelector('#app'));
```
</p>
</details>
<details>
<summary>Finally, use `connect` against your components</summary>
<p>
```javascript
import React, { Component } from 'react';
import { connect } from 'react-redux'; // 👈 import the connect
function TodoList({ todos, addTodo }) {
return (
<div>
{todos.map(({id, text }) => <Todo key={id} text={text} />)}
<AddTodo onSubmit={addTodo} />
</div>
)
}
export default connect(
// 👇 Map to your required state
state => ({ todos: state.todos.items }
// 👇 Map your required actions
dispatch => ({ addTodo: dispatch.todos.addTodo })
)(EditTodo)
```
</p>
</details>
<p> </p>
This is by no means an exhaustive overview of Easy Peasy. We _highly_ recommend you read through the [API](#API) documentation to gain a more full understanding of the tools and helpers that Easy Peasy exposes to you.
<p> </p>
---
## API
Below is an overview of the API exposed by Easy Peasy.
### createStore(model, config)
Creates a Redux store based on the given model. The model must be an object and can be any depth. It also accepts an optional configuration parameter for customisations.
<details>
<summary>Arguments</summary>
<p>
- `model` (Object, required)
Your model representing your state tree, and optionally containing action functions.
- `config` (Object, not required)
Provides custom configuration options for your store. It supports the following options:
- `compose` (Function, not required, default=undefined)
Custom [`compose`](https://redux.js.org/api/compose) function that will be used in place of the one from Redux or Redux Dev Tools. This is especially useful in the context of React Native and other environments. See the Usage with React Native notes.
- `devTools` (bool, not required, default=true)
Setting this to `true` will enable the [Redux Dev Tools Extension](https://github.com/zalmoxisus/redux-devtools-extension).
- `disableInternalSelectFnMemoize` (bool, not required, default=false)
Setting this to `true` will disable the automatic memoisation of a fn that you may return in any of your [`select`](#selectselector) implementations. Please see the [`select`](#selectselector) documentation for more information.
- `enhancers` (Array, not required, default=[])
Any custom [store enhancers](https://redux.js.org/glossary#store-enhancer) you would like to apply to your Redux store.
- `initialState` (Object, not required, default=undefined)
Allows you to hydrate your store with initial state (for example state received from your server in a server rendering context).
- `injections` (Any, not required, default=undefined)
Any dependencies you would like to inject, making them available to your effect actions. They will become available as the 4th parameter to the effect handler. See the [effect](#effectaction) docs for more.
- `middleware` (Array, not required, default=[])
Any additional [middleware](https://redux.js.org/glossary#middleware) you would like to attach to your Redux store.
- `mockActions` (boolean, not required, default=false)
Useful when testing your store, especially in the context of thunks. When set to `true` none of the actions dispatched will update the state, they will be instead recorded and can be accessed via the `getMockedActions` API that is added to the store. Please see the ["Writing Tests"](#writing-tests) section for more information.
- `reducerEnhancer` (Function, not required, default=(reducer => reducer))
Any additional reducerEnhancer you would like to enhance to your root reducer (for example you want to use [redux-persist](https://github.com/rt2zz/redux-persist)).
</p>
</details>
<details>
<summary>Store Instance API</summary>
<p>
When you have created a store all the standard APIs of a [Redux Store](https://redux.js.org/api/store) are available. Please reference [their docs](https://redux.js.org/api/store) for more information. In addition to the standard APIs, Easy Peasy enhances the instance to contain the following:
- `dispatch` (Function & Object, required)
The Redux store `dispatch` behaves as normal, however, it also has the actions from your model directly mounted against it - allowing you to easily dispatch actions. Please see the docs on actions/thunks for examples.
- `getMockedActions` (Function, required)
When the `mockActions` configuration value was passed to the `createStore` then calling this function will return the actions that have been dispatched (and mocked). This is useful in the context of testing - especially thunks.
- `clearMockedActions` (Function, required)
When the `mockActions` configuration value was passed to the `createStore` then calling this function clears the list of mocked actions that have been tracked by the store. This is useful in the context of testing - especially thunks.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { createStore } from 'easy-peasy';
const store = createStore({
todos: {
items: [],
addTodo: (state, text) => {
state.items.push(text)
}
},
session: {
user: undefined,
}
})
```
</p>
</details>
### action
Declares an action on your model. An action allows you to perform updates on your store.
The action will have access to the part of the state tree where it was defined.
<details>
<summary>Arguments</summary>
<p>
- action (Function, required)
The action definition. It receives the following arguments:
- `state` (Object, required)
The part of the state tree that the action is against. You can mutate this state value directly as required by the action. Under the hood we convert these mutations into an update against the Redux store.
- `payload` (Any)
The payload, if any, that was provided to the action.
When your model is processed by Easy Peasy to create your store all of your actions will be made available against the store's `dispatch`. They are mapped to the same path as they were defined in your model. You can then simply call the action functions providing any required payload. See the example below.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { action, createStore } from 'easy-peasy';
const store = createStore({
todos: {
items: [],
add: action((state, payload) => {
state.items.push(payload)
})
}
});
store.dispatch.todos.add('Install easy-peasy');
```
</p>
</details>
### thunk(action)
Declares a thunk action on your model. Allows you to perform effects such as data fetching and persisting.
<details>
<summary>Arguments</summary>
<p>
- action (Function, required)
The thunk action definition. A thunk typically encapsulates side effects (e.g. calls to an API). It can be asynchronous - i.e. use Promises or async/await. Thunk actions cannot modify state directly, however, they can dispatch other actions to do so.
It receives the following arguments:
- `actions` (required)
The actions that are bound to same section of your model as the thunk. This allows you to dispatch another action to update state for example.
- `payload` (Any, not required)
The payload, if any, that was provided to the action.
- `helpers` (Object, required)
Contains a set of helpers which may be useful in advanced cases. The object contains the following properties:
- `dispatch` (required)
The Redux store `dispatch` instance. This will have all the Easy Peasy actions bound to it allowing you to dispatch additional actions.
- `getState` (Function, required)
When executed it will provide the local state of where the thunk is attached to your model. This can be useful in the cases where you require state in the execution of your thunk.
- `getStoreState` (Function, required)
When executed it will provide the root state of your model. This can be useful in the cases where you require state in the execution of your thunk.
- `injections` (Any, not required, default=undefined)
Any dependencies that were provided to the `createStore` configuration will be exposed as this argument. See the [`createStore`](#createstoremodel-config) docs on how to specify them.
- `meta` (Object, required)
This object contains meta information related to the effect. Specifically it contains the following properties:
- parent (Array, string, required)
An array representing the path of the parent to the action.
- path (Array, string, required)
An array representing the path to the action.
This can be represented via the following example:
```javascript
const store = createStore({
products: {
fetchById: thunk((dispatch, payload, { meta }) => {
console.log(meta);
// {
// parent: ['products'],
// path: ['products', 'fetchById']
// }
})
}
});
```
When your model is processed by Easy Peasy to create your store all of your thunk actions will be made available against the store's `dispatch`. They are mapped to the same path as they were defined in your model. You can then simply call the action functions providing any required payload. See the examples below.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { action, createStore, thunk } from 'easy-peasy'; // 👈 import the helper
const store = createStore({
session: {
user: undefined,
// 👇 define your thunk action
login: thunk(async (actions, payload) => {
const user = await loginService(payload)
actions.loginSucceeded(user)
}),
loginSucceeded: action((state, payload) => {
state.user = payload
})
}
});
// 👇 you can dispatch and await on the thunk action
store.dispatch.session.login({
username: 'foo',
password: 'bar'
})
// 👇 thunk actions _always_ return a Promise
.then(() => console.log('Logged in'));
```
</p>
</details>
<details>
<summary>Example accessing local state via the getState parameter</summary>
<p>
```javascript
import { createStore, thunk } from 'easy-peasy';
const store = createStore({
counter: {
count: 1,
// getState allows you to gain access to the local state
// 👇
doSomething: thunk(async (dispatch, payload, { getState }) => {
// Calling it exposes the local state. i.e. the part of state where the
// thunk was attached
// 👇
console.log(getState())
// { count: 1 }
}),
}
});
store.dispatch.doSomething()
```
</p>
</details>
<details>
<summary>Example accessing full state via the getStoreState parameter</summary>
<p>
```javascript
import { createStore, thunk } from 'easy-peasy';
const store = createStore({
counter: {
count: 1,
// getStoreState allows you to gain access to the store's state
// 👇
doSomething: thunk(async (dispatch, payload, { getStoreState }) => {
// Calling it exposes the root state of your store. i.e. the full
// store state 👇
console.log(getStoreState())
// { counter: { count: 1 } }
}),
}
});
store.dispatch.doSomething()
```
</p>
</details>
<details>
<summary>Example dispatching an action from another part of the model</summary>
<p>
```javascript
import { action, createStore, thunk } from 'easy-peasy';
const store = createStore({
audit: {
logs: [],
add: action((state, payload) => {
audit.logs.push(payload);
})
},
todos: {
// dispatch allows you to gain access to the store's dispatch
// 👇
saveTodo: thunk((actions, payload, { dispatch }) => {
// ...
dispatch.audit.add('Added a todo');
})
}
});
store.dispatch.todos.saveTodo('foo');
```
We don't recommned doing this, and instead encourage you to use the [`listen`](#listenon) helper to invert responsibilites. However, there may exceptional cases in which you need to do the above.
</p>
</details>
<details>
<summary>Example with Dependency Injection</summary>
<p>
```javascript
import { createStore, thunk } from 'easy-peasy';
import api from './api' // 👈 a dependency we want to inject
const store = createStore(
{
foo: 'bar',
// injections are exposed here 👇
doSomething: thunk(async (dispatch, payload, { injections }) => {
const { api } = injections
await api.foo()
}),
},
{
// 👇 specify the injections parameter when creating your store
injections: {
api,
}
}
);
store.dispatch.doSomething()
```
</p>
</details>
### reducer(fn)
Declares a section of state to be calculated via a "standard" reducer function - as typical in Redux. This was specifically added to allow for integrations with existing libraries, or legacy Redux code.
Some 3rd party libraries, for example [`connected-react-router`](https://github.com/supasate/connected-react-router), require you to attach a reducer that they provide to your state. This helper will you achieve this.
<details>
<summary>Arguments</summary>
<p>
- fn (Function, required)
The reducer function. It receives the following arguments.
- `state` (Object, required)
The current value of the property that the reducer was attached to.
- `action` (Object, required)
The action object, typically with the following shape.
- `type` (string, required)
The name of the action.
- `payload` (any)
Any payload that was provided to the action.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { createStore, reducer } from 'easy-peasy';
const store = createStore({
counter: reducer((state = 1, action) => {
switch (action.type) {
case 'INCREMENT': state + 1;
default: return state;
}
})
});
store.dispatch({ type: 'INCREMENT' });
store.getState().counter;
// 2
```
</p>
</details>
### select(selector)
Attach derived state (i.e. is calculated from other parts of your state) to your store.
The results of your selectors will be cached, and will only be recomputed if the state that they depend on changes. You may be familiar with `reselect` - this feature provides you with the same benefits.
<details>
<summary>Arguments</summary>
<p>
- selector (Function, required)
The selector function responsible for resolving the derived state. It will be provided the following arguments:
- `state` (Object, required)
The local part of state that the `select` property was attached to.
You can return any derived state you like.
It also supports returning a function. This allows you to support creating a "dynamic" selector that accepts arguments (e.g. `productById(1)`). We will automatically optimise the function that you return - ensuring that any calls to the function will be automatically be memoised - i.e. calls to it with the same arguments will return cached results. This automatic memoisation of the function can be disabled via the `disableInternalSelectFnMemoize` setting on the `createStore`'s config argument.
- dependencies (Array, not required)
If this selector depends on data from other selectors then you should provide the respective selectors within an array to indicate the case. This allows us to make guarantees of execution order so that your state is derived in the manner you expect it to.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { select } from 'easy-peasy'; // 👈 import the helper
const store = createStore({
shoppingBasket: {
products: [{ name: 'Shoes', price: 123 }, { name: 'Hat', price: 75 }],
// 👇 define your derived state
totalPrice: select(state =>
state.products.reduce((acc, cur) => acc + cur.price, 0)
)
}
};
// 👇 access the derived state as you would normal state
store.getState().shoppingBasket.totalPrice;
```
</p>
</details>
<details>
<summary>Example with arguments</summary>
<p>
```javascript
import { select } from 'easy-peasy'; // 👈 import the helper
const store = createStore({
products: [{ id: 1, name: 'Shoes', price: 123 }, { id: 2, name: 'Hat', price: 75 }],
productById: select(state =>
// 👇 return a function that accepts the arguments
id => state.products.find(x => x.id === id)
)
};
// 👇 access the select fn and provide its required arguments
store.getState().productById(1);
// This next call will return a cached result
store.getState().productById(1);
```
</p>
</details>
<details>
<summary>Example with Dependencies</summary>
<p>
```javascript
import { select } from 'easy-peasy';
const totalPriceSelector = select(state =>
state.products.reduce((acc, cur) => acc + cur.price, 0),
)
const netPriceSelector = select(
state => state.totalPrice * ((100 - state.discount) / 100),
[totalPriceSelector] // 👈 declare that this selector depends on totalPrice
)
const store = createStore({
discount: 25,
products: [{ name: 'Shoes', price: 160 }, { name: 'Hat', price: 40 }],
totalPrice: totalPriceSelector,
netPrice: netPriceSelector // price after discount applied
});
```
</p>
</details>
### listen(on)
Allows you to attach listeners to any action or thunk.
This enables parts of your model to respond to actions being fired in other parts of your model. For example you could have a "notifications" model that populates based on certain actions being fired (logged in, product added to basket, etc).
It also supports attach listeners to a "string" named action. This allows with interop with 3rd party libraries, or aids in migration.
Note: If any action being listened to does not complete successfully (i.e. throws an exception), then no listeners will be fired.
<details>
<summary>Arguments</summary>
<p>
- `on` (Function, required)
Allows you to attach a listener to an action. It expects the following arguments:
- `target` (action | thunk | string, required)
The target action you wish to listen to - you provide the direct reference to the action, or the string name of it.
- `handler` (Function, required)
The handler thunk to be executed after the target action is fired successfully. It can be an [`action`](#action) or a [`thunk`](#thunkaction).
The payload for the handler will be the same payload that the target action received
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { action, listen } from 'easy-peasy'; // 👈 import the helper
const userModel = {
user: null,
loggedIn: action((state, user) => {
state.user = user;
}),
logOut: action((state) => {
state.user = null;
})
};
const notificationModel = {
msg: '',
// 👇 you can label your listeners as you like, e.g. "userListeners"
listeners: listen((on) => {
// Thunk handler
on(userModel.loggedIn, thunk(async (actions, payload, helpers) => {
const msg = `${payload.username} logged in`
await auditService.log(msg)
}));
// Action handler
on(userModel.logOut, action((state) => {
state.msg = 'User logged out'
});
})
};
const model = {
user: userModel,
notification: notificationModel
};
```
</p>
</details>
<details>
<summary>Example listening to string named action</summary>
<p>
```javascript
import { listen } from 'easy-peasy';
const model = {
msg: '',
set: (state, payload) => { state.msg = payload; },
listeners: listen((on) => {
// 👇 passing in action name
on('ROUTE_CHANGED', action(actions, payload) => {
// 👆
// We won't know the type of payload, so it will be "any".
// You will have to annotate it manually if you are using
// Typescript and care about the payload type.
actions.set(`Route was changed`);
}));
})
};
```
</p>
</details>
### StoreProvider
Initialises your React application with the store so that your components will be able to consume and interact with the state via the `useStore` and `useActions` hooks.
<details>
<summary>Example</summary>
<p>
```javascript
import { StoreProvider, createStore } from 'easy-peasy';
import model from './model'
const store = createStore(model);
const App = () => (
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
```
</p>
</details>
### useStore(mapState, externals)
A [hook](https://reactjs.org/docs/hooks-intro.html) granting your components access to the store's state.
<details>
<summary>Argument</summary>
<p>
- `mapState` (Function, required)
The function that is used to resolved the piece of state that your component requires. The function will receive the following arguments:
- `state` (Object, required)
The root state of your store.
- `externals` (Array of any, not required)
If your `useStore` function depends on an external value (for example a property of your component), then you should provide the respective value within this argument so that the `useStore` knows to remap your state when the respective externals change in value.
Your `mapState` can either resolve a single piece of state. If you wish to resolve multiple pieces of state then you can either call `useStore` multiple times, or if you like resolve an object within your `mapState` where each property of the object is a resolved piece of state (similar to the `connect` from `react-redux`). The examples will illustrate the various forms.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { useStore } from 'easy-peasy';
const TodoList = () => {
const todos = useStore(state => state.todos.items);
return (
<div>
{todos.map((todo, idx) => <div key={idx}>{todo.text}</div>)}
</div>
);
};
```
</p>
</details>
<details>
<summary>Example resolving multiple values</summary>
<p>
```javascript
import { useStore } from 'easy-peasy';
const BasketTotal = () => {
const totalPrice = useStore(state => state.basket.totalPrice);
const netPrice = useStore(state => state.basket.netPrice);
return (
<div>
<div>Total: {totalPrice}</div>
<div>Net: {netPrice}</div>
</div>
);
};
```
</p>
</details>
<details>
<summary>Example resolving multiple values via an object result</summary>
<p>
```javascript
import { useStore } from 'easy-peasy';
const BasketTotal = () => {
const { totalPrice, netPrice } = useStore(state => ({
totalPrice: state.basket.totalPrice,
netPrice: state.basket.netPrice
}));
return (
<div>
<div>Total: {totalPrice}</div>
<div>Net: {netPrice}</div>
</div>
);
};
```
</p>
</details>
<details>
<summary>A word of caution</summary>
<p>
Please be careful in the manner that you resolve values from your `mapToState`. To optimise the rendering performance of your components we use equality checking (===) to determine if the mapped state has changed.
When an action changes the piece of state your `mapState` is resolving the equality check will break, which will cause your component to re-render with the new state.
Therefore deriving state within your `mapState` in a manner that will always produce a new value (for e.g. an array) is an anti-pattern as it will break our equality checks.
```javascript
// ❗️ Using .map will produce a new array instance every time mapState is called
// 👇
const productNames = useStore(state => state.products.map(x => x.name))
```
You have two options to solve the above.
Firstly, you could just return the products and then do the `.map` outside of your `mapState`:
```javascript
const products = useStore(state => state.products)
const productNames = products.map(x => x.name)
```
Alternatively you could use the [`select`](#selectselector) helper to define derived state against your model itself.
```javascript
import { select, createStore } from 'easy-peasy';
const createStore = ({
products: [{ name: 'Boots' }],
productNames: select(state => state.products.map(x => x.name))
});
```
Note, the same rule applies when you are using the object result form of `mapState`:
```javascript
const { productNames, total } = useStore(state => ({
productNames: state.products.map(x => x.name), // ❗️ new array every time
total: state.basket.total
}));
```
</p>
</details>
### useActions(mapActions)
A [hook](https://reactjs.org/docs/hooks-intro.html) granting your components access to the store's actions.
<details>
<summary>Arguments</summary>
<p>
- `mapActions` (Function, required)
The function that is used to resolved the action(s) that your component requires. Your `mapActions` can either resolve single or multiple actions. The function will receive the following arguments:
- `actions` (Object, required)
The `actions` of your store.
</p>
</details>
<details>
<summary>Example</summary>
<p>
```javascript
import { useState } from 'react';
import { useActions } from 'easy-peasy';
const AddTodo = () => {
const [text, setText] = useState('');
const addTodo = useActions(actions => actions.todos.add);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => addTodo(text)}>Add</button>
</div>
);
};
```
</p>
</details>
<details>
<summary>Example resolving multiple actions</summary>
<p>
```javascript
import { useState } from 'react';
import { useActions } from 'easy-peasy';
const EditTodo = ({ todo }) => {
const [text, setText] = useState(todo.text);
const { saveTodo, removeTodo } = useActions(actions => ({
saveTodo: actions.todos.save,
removeTodo: actions.todo.toggle
}));
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => saveTodo(todo.id)}>Save</button>
<button onClick={() => removeTodo(todo.id)}>Remove</button>
</div>
);
};
```
</p>
</details>
### useDispatch()
A [hook](https://reactjs.org/docs/hooks-intro.html) granting your components access to the store's dispatch.
<details>
<summary>Example</summary>
<p>
```javascript
import { useState } from 'react';
import { useDispatch } from 'easy-peasy';
const AddTodo = () => {
const [text, setText] = useState('');
const dispatch = useDispatch();
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => dispatch({ type: 'ADD_TODO', payload: text })}>Add</button>
</div>
);
};
```
</p>
</details>
<p> </p>
---
## Usage with Typescript
Easy Peasy has full support for Typescript, via its bundled definitions.
We announced our support for Typescript via [this Medium post](https://medium.com/@ctrlplusb/easy-typed-state-in-react-with-hooks-and-typescript-eacd32901f05).
The documentation below will be expanded into higher detail soon, but the combination of the Medium post and the below examples should be enough to get you up and running for now. If anything is unclear please feel free to post and issue and we would be happy to help.
We also have an [example repository](https://github.com/ctrlplusb/easy-peasy-typescript) which you can clone and run for a more interactive run through.
<details>
<summary>Firstly, you need to define a type that represents your model.</summary>
<p>
Easy Peasy exports numerous types to help you declare your model correctly.
```typescript
import { Action, Reducer, Thunk, Select } from 'easy-peasy'
interface TodosModel {
items: Array<string>
// represents a "select"
firstItem: Select<TodosModel, string | void>
// represents an "action"
addTodo: Action<TodosModel, string>
}
interface UserModel {
token?: string
loggedIn: Action<UserModel, string>
// represents a "thunk"
login: Thunk<UserModel, { username: string; password: string }>
}
interface StoreModel {
todos: TodosModel
user: UserModel
// represents a custom reducer
counter: Reducer<number>
}
```
</p>
</details>
<details>
<summary>Then you create your store.</summary>
<p>
```typescript
// Note that as we pass the Model into the `createStore` function. This allows
// full type checking along with auto complete to take place
// 👇
const store = createStore<StoreModel>({
todos: {
items: [],
firstItem: select(state =>
state.items.length > 0 ? state.items[0] : undefined,
),
addTodo: action((state, payload) => {
state.items.push(payload)
}),
},
user: {
token: undefined,
loggedIn: action((state, payload) => {
state.token = payload
}),
login: effect(async (dispatch, payload) => {
const response = await fetch('/login', {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
})
const { token } = await response.json()
dispatch.user.loggedIn(token)
}),
},
counter: reducer((state = 0, action) => {
switch (action.type) {
case 'COUNTER_INCREMENT':
return state + 1
default:
return state
}
}),
})
```
</p>
</details>
<details>
<summary>The store's APIs will be typed</summary>
<p>
```typescript
console.log(store.getState().todos.firstItem)
store.dispatch({ type: 'COUNTER_INCREMENT' })
store.dispatch.todos.addTodo('Install typescript')
```
</p>
</details>
<details>
<summary>You can type your hooks too.</summary>
<p>
``` typescript
import { useStore, useActions, Actions, State } from 'easy-peasy';
import { StoreModel } from './your-store';
function MyComponent() {
const token = useStore((state: State<StoreModel>) =>
state.user.token
)
const login = useActions((actions: Actions<StoreModel>) =>
actions.user.login,
)
return (
<button onClick={() => login({ username: 'foo', password: 'bar' })}>
{token || 'Log in'}
</button>
)
}
```
The above can become a bit cumbersome - having to constantly provide your types to the hooks. Therefore we recommend using the bundled `createTypedHooks` helper in order to create pre-typed versions of the hooks.
```typescript
// hooks.js
import { createTypedHooks } from "easy-peasy";
import { StoreModel } from "./model";
export default createTypedHooks<StoreModel>();
```
We could then revise our previous example.
``` typescript
import { useStore, useActions } from './hooks';
function MyComponent() {
const token = useStore((state) => state.user.token)
const login = useActions((actions) => actions.user.login)
return (
<button onClick={() => login({ username: 'foo', password: 'bar' })}>
{token || 'Log in'}
</button>
)
}
```
That's far cleaner - and it's still fully type checked.
</p>
</details>
<details>
<summary>We also support typing `react-redux` based integrations.</summary>
<p>
```typescript
const Counter: React.SFC<{ counter: number }> = ({ counter }) => (
<div>{counter}</div>
)
connect((state: State<StoreModel>) => ({
counter: state.counter,
}))(Counter)
```
</p>
</details>
<p> </p>
---
## Usage with React Native
Easy Peasy is platform agnostic but makes use of features that may not be available in all environments.
<details>
<summary>How to enable remote Redux dev tools</summary>
<p>
React Native, hybrid, desktop and server side Redux apps can use Redux Dev Tools using the [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools) library.
To use this library, you will need to pass the DevTools compose helper as part of the [config object](#createstoremodel-config) to `createStore`
```javascript
import { createStore } from 'easy-peasy';
import { composeWithDevTools } from 'remo