NEWS: v6.3.0 fixed a nasty bug that could render zombie children. Please update to this version at least to save yourself some headaches. Thanks!
Table of Contents
Introduction :wave:
React Easy State is a practical state management library with two functions and two accompanying rules.
- Always wrap your components with
view()
. - Always wrap your state store objects with
store()
.
import React from 'react';
import { store, view } from '@risingstack/react-easy-state';
const counter = store({
num: 0,
increment: () => counter.num++
});
export default view(() => (
<button onClick={counter.increment}>{counter.num}</button>
));
This is enough for it to automatically update your views when needed. It doesn't matter how you structure or mutate your state stores, any syntactically valid code works.
Check this TodoMVC codesandbox or raw code for a more exciting example with nested data, arrays and getter properties.
Installation :cd:
npm install @risingstack/react-easy-state
Setting up a quick project
Easy State supports Create React App without additional configuration. Just run the following commands to get started.
npx create-react-app my-app
cd my-app
npm install @risingstack/react-easy-state
npm start
You need npm 5.2+ to use npx.
Everyday Usage :sunglasses:
Creating global stores
store
creates a state store from the passed object and returns it. A state store behaves just like the passed object. (To be precise, it is a transparent reactive proxy of the original object.)
import { store } from '@risingstack/react-easy-state';
const user = store({ name: 'Rick' });
user.name = 'Bob';
State stores may have arbitrary structure and they may be mutated in any syntactically valid way.
import { store } from '@risingstack/react-easy-state';
const user = store({
profile: {
firstName: 'Bob',
lastName: 'Smith',
get name() {
return `${user.profile.firstName} ${user.profile.lastName}`;
},
},
hobbies: ['programming', 'sports'],
friends: new Map(),
});
user.profile.firstName = 'Bob';
delete user.profile.lastName;
user.hobbies.push('reading');
user.friends.set('id', otherUser);
Async operations can be expressed with the standard async/await syntax.
import { store } from '@risingstack/react-easy-state';
const userStore = store({
user: {},
async fetchUser() {
userStore.user = await fetch('/user');
},
});
export default userStore;
State stores may import and use other state stores in their methods.
userStore.js
import { store } from '@risingstack/react-easy-state';
const userStore = store({
user: {},
async fetchUser() {
userStore.user = await fetch('/user');
},
});
export default userStore;
recipesStore.js
import { store } from '@risingstack/react-easy-state';
import userStore from './userStore';
const recipesStore = store({
recipes: [],
async fetchRecipes() {
recipesStore.recipes = await fetch(
`/recipes?user=${userStore.user.id}`,
);
},
});
export default recipesStore;
Wrap your state stores with store
as early as possible.
const person = { name: 'Bob' };
person.name = 'Ann';
export default store(person);
const person = store({ name: 'Bob' });
person.name = 'Ann';
export default person;
The first example wouldn't trigger re-renders on the person.name = 'Ann'
mutation, because it is targeted at the raw object. Mutating the raw - none store
-wrapped object - won't schedule renders.
Avoid using the this
keyword in the methods of your state stores.
import { store, view } from '@risingstack/react-easy-state';
const counter = store({
num: 0,
increment() {
this.num++;
counter.num++;
},
});
export default view(() => (
<div onClick={counter.increment}>{counter.num}</div>
));
this.num++
won't work, because increment
is passed as a callback and loses its this
. You should use the direct object reference - counter
- instead of this
.
Creating reactive views
Wrapping your components with view
turns them into reactive views. A reactive view re-renders whenever a piece of store - used inside its render - changes.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob' });
export default view(() => (
<div>
<input
value={user.name}
onChange={ev => (user.name = ev.target.value)}
/>
<div>Hello {user.name}!</div>
</div>
));
Wrap ALL of your components with view
- including class and function ones - even if they don't seem to directly use a store.
Every component that is using a store or part of a store inside its render must be wrapped with view
. Sometimes store usage is not so explicit and easy to to miss.
import { view, store } from '@risingstack/react-easy-state';
const appStore = store({
user: { name: 'Ann' },
});
const App = view(() => (
<div>
<h1>My App</h1>
<Profile user={appStore.user} />
</div>
));
const Profile = view(({ user }) => <p>Name: {user.name}</p>);
const Profile = ({ user }) => <p>Name: {user.name}</p>;
If you are 100% sure that your component is not using any stores you can skip the view
wrapper.
import React from 'react';
export default (() => <p>This is just plain text</p>);
view
wrapping is advised even in these cases though.
- It saves you from future headaches as your project grows and you start to use stores inside these components.
view
is pretty much equivalent to memo
if you don't use any stores. That is nearly always nice to have.
A single reactive component may use multiple stores inside its render.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob' });
const timeline = store({ posts: ['react-easy-state'] });
export default view(() => (
<div>
<div>Hello {user.name}!</div>
<div>Your first post is: {timeline.posts[0]}</div>
</div>
));
view
implements an optimal shouldComponentUpdate
(or memo
) for your components.
-
Using PureComponent
or memo
will provide no additional performance benefits.
-
Defining a custom shouldComponentUpdate
may rarely provide performance benefits when you apply some use case specific heuristics inside it.
Reactive renders are batched. Multiple synchronous store mutations won't result in multiple re-renders of the same component.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob', age: 30 });
function mutateUser() {
user.name = 'Ann';
user.age = 32;
}
export default view(() => (
<div onClick={mutateUser}>
name: {user.name}, age: {user.age}
</div>
));
If you mutate your stores multiple times synchronously from exotic task sources, multiple renders may rarely happen. If you experience performance issues you can batch changes manually with the batch
function. batch(fn)
executes the passed function immediately and batches any subsequent re-renders until the function execution finishes.
import React from 'react';
import { view, store, batch } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob', age: 30 });
function mutateUser() {
batch(() => {
user.name = 'Ann';
user.age = 32;
});
}
export default view(() => (
<div>
name: {user.name}, age: {user.age}
</div>
));
NOTE: The React team plans to improve render batching in the future. The batch
function and built-in batching may be deprecated and removed in the future in favor of React's own batching.
Always apply view
as the latest (innermost) wrapper when you combine it with other Higher Order Components.
import { view } from '@risingstack/react-easy-state';
import { withRouter } from 'react-router-dom';
import { withTheme } from 'styled-components';
const Comp = () => <div>A reactive component</div>;
withRouter(view(Comp));
withTheme(view(Comp));
view(withRouter(Comp));
view(withTheme(Comp));
Usage with (pre v4.4) React Router.
-
If routing is not updated properly, wrap your view(Comp)
- with the Route
s inside - in withRouter(view(Comp))
. This lets react-router know when to update.
-
The order of the HOCs matter, always use withRouter(view(Comp))
.
This is not necessary if you use React Router 4.4+.
Usage with React Developer Tools.
If you want React Developer Tools to recognize your reactive view components' names, you have to pass either a named function or an anonymous function with name inference to the view
wrapper.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
const user = store({
name: 'Rick',
});
const componentName = () => (
<div>{user.name}</div>
);
export default view(componentName);
Passing nested data to third party components.
Third party helpers - like data grids - may consist of many internal components which can not be wrapped by view
, but sometimes you would like them to re-render when the passed data mutates. Traditional React components re-render when their props change by reference, so mutating the passed reactive data won't work in these cases. You can solve this issue by deep cloning the observable data before passing it to the component. This creates a new reference for the consuming component on every store mutation.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
import Table from 'rc-table';
import cloneDeep from 'lodash/cloneDeep';
const dataStore = store({
items: [
{
product: 'Car',
value: 12,
},
],
});
export default view(() => (
<Table data={cloneDeep(dataStore.items)} />
));
Creating local stores
A singleton global store is perfect for something like the current user, but sometimes having local component states is a better fit. Just create a store inside a function component or as a class component property in these cases.
Local stores in function components
import React from 'react'
import { view, store } from '@risingstack/react-easy-state'
export default view(() => {
const counter = store({ num: 0 })
const increment = () => counter.num++
return <button={increment}>{counter.num}</button>
})
Local stores in functions rely on React hooks. They require React and React DOM v16.8+ or React Native v0.59+ to work.
You can use React hooks - including useState
- in function components, Easy State won't interfere with them. Consider using autoEffect instead of the useEffect
hook for the best experience though.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
export default view(() => {
const [name, setName] = useState('Ann');
const user = store({ age: 30 });
return (
<div>
<input value={name} onChange={ev => setName(ev.target.value)} />
<input
value={user.age}
onChange={ev => (user.age = ev.target.value)}
/>
</div>
);
});
Local stores in class components
import React, { Component } from 'react';
import { view, store } from '@risingstack/react-easy-state';
class Counter extends Component {
counter = store({ num: 0 });
increment = () => counter.num++;
render() {
return (
<button onClick={this.increment}>{this.counter.num}</button>
);
}
}
export default view(Counter);
You can also use vanilla setState
in your class components, Easy State won't interfere with it.
import React, { Component } from 'react';
import { view, store } from '@risingstack/react-easy-state';
class Profile extends Component {
state = { name: 'Ann' };
user = store({ age: 30 });
setName = ev => this.setState({ name: ev.target.value });
setAge = ev => (this.user.age = ev.target.value);
render() {
return (
<div>
<input value={this.state.name} onChange={this.setName} />
<input value={this.user.age} onChange={this.setAge} />
</div>
);
}
}
export default view(Profile);
Don't name local stores as state
. It may conflict with linter rules, which guard against direct state mutations.
import React, { Component } from 'react';
import { view, store } from '@risingstack/react-easy-state';
class Profile extends Component {
state = store({});
user = store({});
render() {}
}
Deriving local stores from props (getDerivedStateFromProps
).
Class components wrapped with view
have an extra static deriveStoresFromProps
lifecycle method, which works similarly to the vanilla getDerivedStateFromProps
.
import React, { Component } from 'react';
import { view, store } from '@risingstack/react-easy-state';
class NameCard extends Component {
userStore = store({ name: 'Bob' });
static deriveStoresFromProps(props, userStore) {
userStore.name = props.name || userStore.name;
}
render() {
return <div>{this.userStore.name}</div>;
}
}
export default view(NameCard);
Instead of returning an object, you should directly mutate the received stores. If you have multiple local stores on a single component, they are all passed as arguments - in their definition order - after the first props argument.
Advanced Usage :nerd_face:
Adding side effects
Use autoEffect
to react with automatic side effect to your store changes. Auto effects should contain end-of-chain logic - like changing the document title or saving data to LocalStorage. view
is a special auto effect that does rendering.
Never use auto effects to derive data from other data. Use dynamic getters instead.
import { store, autoEffect } from '@risingstack/react-easy-state';
const store1 = store({ name: 'Store 1' })
const store2 = store({ name: 'Store 2' })
autoEffect(() => store2.name = store1.name)
const store1 = store({ name: 'Store 1' })
const store2 = store({ get name () { return store1.name } })
Global auto effects
Global auto effects can be created with autoEffect
and cleared up with clearEffect
.
import { store, autoEffect, clearEffect } from '@risingstack/react-easy-state';
const app = store({ name: 'My App' })
const effect = autoEffect(() => document.title = app.name)
app.name = 'My Awesome App'
clearEffect(effect)
app.name = 'My App'
Local auto effects in function components
Use local auto effects in function components instead of the useEffect
hook when reactive stores are used inside them. These local effects are automatically cleared when the component unmounts.
import React from 'react'
import { store, view, autoEffect } from '@risingstack/react-easy-state';
export default view(() => {
const app = store({ name: 'My App' })
autoEffect(() => document.title = app.name)
})
Explicitly pass none reactive dependencies - like vanillas props and state - to local auto effects in function components.
Because of the design of React hooks you have to explicitly pass all none reactive data to a hook-like dependency array. This makes sure that the effect also runs when the none reactive data changes.
import React from 'react'
import { store, view, autoEffect } from '@risingstack/react-easy-state';
export default view(({ greeting }) => {
const app = store({ name: 'My App' })
autoEffect(() => document.title = `${greeting} ${app.name}`, [greeting])
})
Local auto effects in class components
Local effects in class components must be cleared when the component unmounts.
import React, { Component } from 'react'
import { store, view, autoEffect } from '@risingstack/react-easy-state';
class App extends Component {
app = store({ name: 'My App' })
componentDidMount () {
this.effect = autoEffect(() => document.title = this.app.name)
}
componentWillUnmount () {
clearEffect(this.effect)
}
}
API Summary :book:
store(obj)
Creates an observable store from the passed object and returns it. Can be used outside components for global stores and inside components for local stores.
import { store } from '@risingstack/react-easy-state';
const user = store({ name: 'Rick' });
view(Comp)
Creates a reactive view from the passed component and returns it. A reactive view re-renders whenever any store data used inside it is mutated.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob' });
export default view(() => (
<div>Hello {user.name}!</div>
));
batch(fn)
Immediately executes the passed function and batches all store mutations inside it. Batched mutations are guaranteed to not trigger unnecessary double renders. Most task sources are batched automatically, only use batch
if you encounter performance issues.
import React from 'react';
import { view, store } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob' });
function setName() {
batch(() => {
user.name = 'Rick'
user.name = 'Ann'
})
}
autoEffect(fn)
Creates a reactive function from the passed one, immediately executes it, and returns it. A reactive function automatically re-reruns whenever any store data used inside it is mutated.
Can be used both outside and inside components.
import { store, autoEffect } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob' })
autoEffect(() => document.title = user.name)
clearEffect(fn)
Takes a reactive function (returned by autoEffect
) and clears the reactivity from it. Cleared reactive functions will no longer re-rerun on related store mutations. Reactive functions created inside function components are automatically cleared when the component unmounts.
import { store, autoEffect, clearEffect } from '@risingstack/react-easy-state';
const user = store({ name: 'Bob' })
const effect = autoEffect(() => document.title = user.name)
clearEffect(effect)
Examples with live demos :tv:
Beginner
Advanced
Articles :loudspeaker:
Performance :rocket:
You can compare Easy State with plain React and other state management libraries with the below benchmarks. It performs a bit better than MobX and similarly to Redux.
Platform support :computer:
- Node: 6 and above
- Chrome: 49 and above
- Firefox: 38 and above
- Safari: 10 and above
- Edge: 12 and above
- Opera: 36 and above
- React Native: 0.59 and above
This library is based on non polyfillable ES6 Proxies. Because of this, it will never support IE.
Contributors :sparkles:
Contributions are always welcome, please read our contributing documentation.
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind welcome!