Note: this package depends on the new Hooks feature of React. Currently available via 16.7.0-alpha.0 of React.
Easy peasy global state for React
import { StoreProvider, createStore, useStore, useAction } from 'easy-peasy';
// π create your store by providing your model
const store = createStore({
todos: {
items: ['Install easy-peasy', 'Build app', 'Profit'],
// π define actions
add: (state, payload) => {
state.items.push(payload) // π you mutate state to update (we convert
} // to immutable updates)
} //
}); // OR
//
// You can return new "immutable" state:
// return {
// ...state,
// items: [...state.items, payload ]
// };
const App = () => (
// π surround your app with the provider to expose the store to your app
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
function TodoList() {
// π use hooks to get state or actions. your component will receive
// updated state automatically
const todos = useStore(state => state.todos.items)
const add = useAction(dispatch => dispatch.todos.add)
return (
<div>
{todos.map((todo, idx) => <div key={idx}>{todo}</div>)}
<AddTodo onAdd={add} />
</div>
)
}
Features
- Quick to set up, easy to use
- Update state via simple mutations (thanks
immer
) - Derived state
- Async actions for remote data fetching/persisting
- Provides React Hooks to interact with the store π
- Powered by Redux
- Supports custom Redux middleware
- Supports Redux root reducer enhancement
- Supports Redux Dev Tools
TOCs
- Introduction
- Installation
- Examples
- Core Concepts
- Usage with React
- Usage with React Native
- API
- Prior Art
Introduction
Easy Peasy gives you the power of Redux (and its tooling) whilst avoiding the boilerplate. It allows you to create a full Redux store by defining a model that describes your state and it's actions. Batteries are included - you don't need to configure any additional packages to support derived state, side effects, or integration with your React components. In terms of integration with React we are leveraging the cutting edge Hooks feature. It's a game changer, and we highly recommend you give it a go.
Installation
Firsly, ensure you have the correct versions of React (i.e. a version that supports Hooks) installed.
npm install react@16.7.0-alpha.0
npm install react-dom@16.7.0-alpha.0
Then install Easy Peasy.
npm install easy-peasy
You're off to the races.
Examples
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
Core Concepts
The below will introduce you to the core concepts of Easy Peasy. At first we will interact with the store directly (we output a standard Redux store). In the following section we shall then cover how to use Easy Peasy within a React application.
Setting up your store
Firstly you need to define your model. This represents the structure of your Redux store along with the default values. It 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.
const model = {
todos: {
items: [],
}
};
Then you provide your model to createStore
.
import { createStore } from 'easy-peasy';
const store = createStore(model);
You will now have a Redux store. π
Accessing state directly via the store
You can access your store's state using the getState
API of the store.
store.getState().todos.items;
Modifying state via actions
In order to mutate your state you need to define an action against your model.
const store = createStore({
todos: {
items: [],
// π our action
addTodo: (state, payload) => {
// Mutate the state directly. Under the hood we convert this to an
// an immutable update in the store, but at least you don't need to
// worry about being careful to return new instances etc. This also
// π makes it easy to update deeply nested items.
state.items.push(payload)
}
}
});
The action will receive as it's 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 state
. 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 return new "immutable" instances of your state if you prefer:
addTodo: (state, payload) => { return { ...state, items: [...state.items, paylaod] }; }
Dispatching actions directly via the store
Easy Peasy will bind your actions against the store's dispatch
using a path that matches where the action was defined against your model. You can dispatch your actions directly via the store, providing any payload that they may require.
store.dispatch.todos.addTodo('Install easy-peasy');
// |-------------|
// |-- path matches our model (todos.addTodo)
Check your state and you should see that it is updated.
store.getState().todos.items;
// ['Install easy-peasy']
effect
action
Creating an If you wish to perform side effects, such as fetching or persisting data from your server then you can use the effect
helper to declare an effectful action.
import { effect } from 'easy-peasy'; // π import the helper
const store = createStore({
todos: {
items: [],
// π define an action surrounding it with the helper
saveTodo: effect(async (dispatch, payload, getState) => {
// π
// Notice that an effect 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
dispatch.todos.todoSaved(saved);
}),
todoSaved: (state, payload) => {
state.items.push(payload)
}
}
});
As you can see in the example above you can't modify the state directly within an effect
action, however, the effect
action is provided dispatch
, allowing you dispatch actions to update the state where required.
effect
action directly via the store
Dispatching an You dispatch an effectful action in the same manner as a normal action. However, an effect
action always returns a Promise allowing you to chain commands to execute after the effect
action has completed.
store.dispatch.todos.saveTodo('Install easy-peasy').then(() => {
console.log('Todo saved');
})
select
Deriving state via If you have state that can be derived from state then you can use the select
helper. Simply attach it to any part of your model.
import { select } from 'easy-peasy'; // π import then 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
to alleviate this issue, however, with Easy Peasy it's this feature is baked right in.
You can attach selectors to any part of your state. Similar to actions they will receive the local state that they are attached to and can access all the state down that branch of state.
Accessing Derived State directly via the store
You can access derived state as though it were a standard piece of state.
store.getState().shoppingBasket.totalPrice
Note! See how we don't call the derived state as a function. You access it as a simple property.
Final notes
Now that you have gained an understanding of the store we suggest you read the section on Usage with React to learn how to use Easy Peasy in your React apps.
Oh! And don't forget to install the Redux Dev Tools Extension to visualise your actions firing along with the associated state updates. π
Usage with React
With the new Hooks feature introduced in React v16.7.0 it's never been easier to provide a mechanism to interact with global state in your components. We have provided two hooks, allowing you to access the state and actions from your store.
If you aren't familiar with hooks yet we highly recommend that you read the official documentation and try playing with our examples. Hooks are truly game changing and will simplify your components dramatically.
Wrap your app with StoreProvider
Firstly we will need to create your store and wrap your application with the StoreProvider
.
import { StoreProvider, createStore } from 'easy-peasy';
import model from './model'
const store = createStore(model);
const App = () => (
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
Consuming state in your Components
To access state within your components you can use the useStore
hook.
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. Then you should provide the respective "external" within the second argument to the usStore
. The useStore
hook will then track the external value and ensure to recalculate the mapped state if any of the external values change.
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 to gain a full understanding of the behaviours and pitfalls of the hook.
Firing actions in your Components
In order to fire actions in your components you can use the useAction
hook.
import { useState } from 'react';
import { useAction } from 'easy-peasy';
const AddTodo = () => {
const [text, setText] = useState('');
const addTodo = useAction(dispatch => dispatch.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 useAction
hook.
Alternative 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
package.
react-redux
package
First, install the npm install react-redux
Provider
Then wrap your app with the 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'));
connect
against your components
Finally, use 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)
Usage with React Native
Easy Peasy is platform agnostic but makes use of features that may not be available in all environments.
Remote Redux Dev Tools
React Native, hybrid, desktop and server side Redux apps can use Redux Dev Tools using the Remote Redux DevTools library.
To use this library, you will need to pass the DevTools compose helper as part of the config object to createStore
import { createStore } from 'easy-peasy';
import { composeWithDevTools } from 'remote-redux-devtools';
import model from './model';
const store = createStore(model, {
config: {
compose: composeWithDevTools({ realtime: true }),
}
);
See https://github.com/zalmoxisus/remote-redux-devtools#parameters for all configuration options.
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.
Arguments
-
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
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. -
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 docs for more.
-
middleware
(Array, not required, default=[])Any additional middleware you would like to attach to your Redux store.
-
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).
-
Example
import { createStore } from 'easy-peasy';
const store = createStore({
todos: {
items: [],
addTodo: (state, text) => {
state.items.push(text)
}
},
session: {
user: undefined,
}
})
action
A function assigned to your model will be considered an action, which can be be used to dispatch updates to your store.
The action will have access to the part of the state tree where it was defined.
It has 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.
Example
import { createStore } from 'easy-peasy';
const store = createStore({
todos: {
items: [],
add: (state, payload) => {
state.items.push(payload)
}
},
user: {
preferences: {
backgroundColor: '#000',
changeBackgroundColor: (state, payload) => {
state.backgroundColor = payload;
}
}
}
});
store.dispatch.todos.add('Install easy-peasy');
store.dispatch.user.preferences.changeBackgroundColor('#FFF');
effect(action)
Declares an action on your model as being effectful. i.e. has asynchronous flow.
Arguments
-
action (Function, required)
The action function to execute the effects. It can be asynchronous, e.g. return a Promise or use async/await. Effectful actions cannot modify state, however, they can dispatch other actions providing fetched data for example in order to update the state.
It accepts the following arguments:
-
dispatch
(required)The Redux store
dispatch
instance. This will have all the Easy Peasy actions bound to it allowing you to dispatch additional actions. -
payload
(Any, not required)The payload, if any, that was provided to the action.
-
getState
(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 effectful action.
-
injections
(Any, not required, default=undefined)Any depenencies that were provided to the
createStore
configuration will be exposed as this argument. See thecreateStore
docs on how to specify them.
-
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.
Example
import { createStore, effect } from 'easy-peasy'; // π import then helper
const store = createStore({
session: {
user: undefined,
// π define your effectful action
login: effect(async (dispatch, payload) => {
const user = await loginService(payload)
dispatch.session.loginSucceeded(user)
}),
loginSucceeded: (state, payload) => {
state.user = payload
}
}
});
// π you can dispatch and await on the effectful actions
store.dispatch.session.login({
username: 'foo',
password: 'bar'
})
// π effectful actions _always_ return a Promise
.then(() => console.log('Logged in'));
Example accessing State via the getState parameter
import { createStore, effect } from 'easy-peasy';
const store = createStore({
foo: 'bar',
// getState allows you to gain access to the store's state
// π
doSomething: effect(async (dispatch, payload, getState, injections) => {
// Calling it exposes the root state of your store. i.e. the full
// store state π
console.log(getState())
// { foo: 'bar' }
}),
});
store.dispatch.doSomething()
Example with Dependency Injection
import { createStore, effect } from 'easy-peasy';
import api from './api' // π a dependency we want to inject
const store = createStore(
{
foo: 'bar',
// injections are exposed here π
doSomething: effect(async (dispatch, payload, getState, injections) => {
const { api } = injections
await api.foo()
}),
},
{
// π specify the injections parameter when creating your store
injections: {
api,
}
}
);
store.dispatch.doSomething()
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
, require you to attach a reducer that they provide to your state. This helper will you achieve this.
Arguments
-
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.
-
-
Example
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
select(selector)
Declares a section of state that is derived via the given selector function.
Arguments
-
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.
-
-
dependencies (Array, not required)
If this selector depends on other selectors your need to pass these selectors in here to indicate that is the case. Under the hood we will ensure the correct execution order.
Select's have their outputs cached to avoid unnecessary work, and will be executed any time their local state changes.
Example
import { select } from 'easy-peasy'; // π import then 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;
Example with Dependencies
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
});
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 useAction
hooks.
Example
import { StoreProvider, createStore } from 'easy-peasy';
import model from './model'
const store = createStore(model);
const App = () => (
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
useStore(mapState, externals)
A hook granting your components access to the store's state.
Arguments
-
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 theuseStore
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.
Example
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>
);
};
Example resolving multiple values
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>
);
};
Example resolving multiple values via an object result
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>
);
};
A word of caution
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.
// βοΈ 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
:
const products = useStore(state => state.products)
const productNames = products.map(x => x.name)
Alternatively you could use the select
helper to define derived state against your model itself.
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
:
const { productNames, total } = useStore(state => ({
productNames: state.products.map(x => x.name), // βοΈ new array every time
total: state.basket.total
}));
useAction(mapAction)
A hook granting your components access to the store's actions.
Arguments
-
mapAction
(Function, required)The function that is used to resolved the action that your component requires. The function will receive the following arguments:
-
dispatch
(Object, required)The
dispatch
of your store, which has all the actions mapped against it.
-
Your mapAction
can either resolve a single action. If you wish to resolve multiple actions then you can either call useAction
multiple times, or if you like resolve an object within your mapAction
where each property of the object is a resolved action. The examples below will illustrate these options.
Example
import { useState } from 'react';
import { useAction } from 'easy-peasy';
const AddTodo = () => {
const [text, setText] = useState('');
const addTodo = useAction(dispatch => dispatch.todos.add);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => addTodo(text)}>Add</button>
</div>
);
};
Example resolving multiple actions via an object map
import { useState } from 'react';
import { useAction } from 'easy-peasy';
const EditTodo = ({ todo }) => {
const [text, setText] = useState(todo.text);
const { saveTodo, removeTodo } = useAction(dispatch => ({
saveTodo: dispatch.todos.save,
removeTodo: dispatch.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>
);
};
Prior art
This library was massively inspired by the following awesome projects. I tried to take the best bits I liked about them all and create this package. Huge love to all contributors involved in the below.
-
Rematch is Redux best practices without the boilerplate. No more action types, action creators, switch statements or thunks.
-
Simple React state management. Made with β€οΈ and ES6 Proxies.
-
Model Driven State Management