overstated
v2.2.2
Published
React state management library that's delightful to use, without sacrificing performance or scalability.
Downloads
20
Maintainers
Readme
Overstated
React state management library that's delightful to use, without sacrificing performance or scalability.
Features
- Easy: there's barely anything you need to learn before you can start using Overstated, you just have to wrap your app with
Provider
, define some initial state and some methods for updating it in aStore
and then access the state and methods you need in your components either via theuseStore
hook orconnect
. - Performant: Overstated has been designed from the ground up with performance in mind, everything you need to write very performant applications (selector function,
autosuspend
,debug
) is built-in. - Scalable: you can write big apps with this. You can
connect
to multiple stores with a single call,compose
multiple stores easily and usemiddlewares
. - TypeScript-ready: Overstated has been written in TypeScript and enables you to get a fully typed app with minimal effort.
Check out the FAQ section for some commonly asked questions.
Install
npm install --save overstated
Usage
Provider
The Provider
component needs to wrap your app in order for this to work:
import * as React from 'react';
import {render} from 'react-dom';
import App from './app';
import {Provider} from 'overstated';
render (
<Provider>
<App />
</Provider>,
document.getElementById ( 'app' )
);
- ℹ️ An error will be thrown if you forget to do this.
- ℹ️ You can inject some stores that will be used instead of the regular ones, even if you are pre-instantiating them, via the
inject
attribute:<Provider inject={[StoreInstanceFoo, StoreInstanceBar]}>
, this is useful for testing purposes.
Store
The Store
is where you define your initial state and the methods for updating it. Then your components will retrieve the state and methods they need via useStore
or connect
and they will be re-rendered when the state changes.
Core methods
This is the core interface of a Store
:
class Store<State> {
state: State = {};
setState ( updater: State | Function, callback?: Function ): Promise<void>;
}
It is modeled after a React class component and it works in a similar way:
- There's a
state
object, which:- Must be a plain object, other kinds of data (primitives, arrays, functions etc.) aren't supported.
- You must never mutate directly.
- There's a
setState
method, which:- Can accept either a new state object (which will be merged with the current one) or a function, which will be called with the current state as its first argument, which should return a new state object.
- Can accept a callback function, which will be called once the state has been updated.
- Returns a promise, which you can
await
if you need to wait until the state has been updated.
You define your stores by extending the default one:
import {Store} from 'overstated';
class CounterStore extends Store<{value: number}> {
state = {
value: 0
}
increment = () => {
return this.setState ( state => ({ value: state.value + 1 }) );
}
decrement = () => {
return this.setState ( state => ({ value: state.value + 1 }) );
}
}
- ℹ️ If you're using TypeScript you should provide the type of the state object.
- ℹ️ Never mutate the state directly (
this.state.foo = 123
), always usesetState
(this.setState ({ foo: 123 })
). - ℹ️ Don't read state immediately after setting it, as it won't be updated immediately.
- ℹ️ If the next state depends on the current state you should call
setState
with a function, to make sure that the state is computed properly and you aren't using an older version of the state object. - ℹ️ You should always define your methods like this:
foo = () => {}
rather than like this:foo () {}
so that you will never have to callFunction#bind
on them when using them. - ℹ️ If your method is updating the state you should always
return
the value returned bysetState
, so that other methods canawait
it if needed. - ℹ️ In general stores are just plain classes, so testing them is easy.
Suspension
Overstated's stores have the ability to "suspend" React re-renders that would otherwise occur when their state changes, this is useful when you want to update the state many times but only want to cause one re-render. This is the related interface:
class Store {
suspend ( options?: SuspensionOptions ): void
unsuspend ( options?: SuspensionOptions, callback?: Function ): Promise<void>
isSuspended (): boolean
}
The supported options object has the following interface:
{
propagateUp: false, // Whether to propagate up the suspension to parents
propagateDown: false // Whether to propagate down the suspension to children
}
The
suspend
method allows you to suspend re-renders.- Can accept a options object, useful for propagating the suspension up to parent stores and/or down to child stores, read more about them in the
compose
section.
- Can accept a options object, useful for propagating the suspension up to parent stores and/or down to child stores, read more about them in the
The
unsuspend
method allows you to unsuspend re-renders: if before callingunsuspend
a re-render has been prevented it will now be triggered.- Can accept a options object, useful for propagating the suspension up to parent stores and/or down to child stores, read more about them in the
compose
section. - Can accept a callback function, which will be called after any potential re-renders caused by this function.
- Can accept a options object, useful for propagating the suspension up to parent stores and/or down to child stores, read more about them in the
The
isSuspended
method allows you to check if re-renders are currenly suspended.ℹ️ If for instance you call
suspend
3 times you should also callunsuspend
3 times to resume operating as normal.ℹ️ Using this APIs directly can be cumbersome, as you'd probably want to use it in all of your methods that update the state, and you'd have to make sure that
unsuspend
gets properly called even if your methods throw an error, otherwise re-renders will remain suspended forever. Instead you should useautosuspend
, which takes care of everything for you.
autosuspend
autosuspend
is a method for automatically and safely suspend
-ing React re-renders when a method gets called and unsuspending them right before returning from it. This is useful for ensuring that even if your methods update the state many times they only cause 1 re-render. You might not want this behavior, so it's opt-in:
import CounterStore from './counter_store';
CounterStore.autosuspend ();
The autosuspend
method accepts an option object with the following shape:
const options = {
children: true, // Whether to autosuspend children too
methods: /^(?!_|(?:(?:get|has|is)(?![a-z0-9])))/, // Methods matching this regex will be autosuspended
methodsInclude: undefined, // Methods matching this regex will be autosuspended, has higher priority over the "methods" regex
methodsExclude: undefined, // Methods matching this regex will be autosuspended, has higher priority over the "methods" regex and the "methodsInclude" regex
propagateUp: false, // Whether to propagate up the suspension to parents
propagateDown: false // Whether to propagate down the suspension to children
};
methods
is a regex, if your method's name matches that regex then it will be autosuspended. By default methods starting with_
,get
,has
oris
won't be autosuspended, as we are assuming that those won't be updating the state.methodsInclude
is a regex, if your method's name matches that regex then it will be autosuspended, has higher proprity overmethods
.methodsExclude
is a regex, if your method's name matches that regex then it will not be autosuspended, has higher proprity overmethods
andmethodsInclude
.propagateUp
checks whether parent stores (read more about them in thecompose
section) should get suspended too, generally you won't need to set this option manually.propagateDown
checks whether children stores (read more about them in thecompose
section) should get suspended too, generally you won't need to set this option manually.children
checks whether children stores (read more about them in thecompose
section) should get autosuspended too, generally you won't need to set this option manually.ℹ️ If you try to autosuspend a store twice an error will be thrown.
ℹ️ When autosuspending a store its children stores (if any) will be autosuspended too, read more about them in the
compose
section.ℹ️ You can also set those options in each store's class as the value of their
autosuspendOptions
instance property.ℹ️ It's recommended to include or exclude new methods via the
methodsInclude
andmethodsExclude
optional regexes, so that you won't have to manually modify themethods
regex.ℹ️ Since this relies on the individual methods' names you'll have to make sure that your minifier isn't minifing function names.
middlewares
You can register and unregister middlewares on each store, they will be called every time the store gets updated, including if it was updated from within a middleware, and they will receive the previous state object as their first argument. This is the related interface:
class Store {
middlewares: Function[] = []; // Array of currently registered middlewares
registerMiddleware ( middleware: Function ): void;
unregisterMiddleware ( middleware: Function ): void;
}
middlewares
contains all the currently registered middlewares.registerMiddleware
allows you to add a new middleware.unregisterMiddleware
allows you to remove an existing middleware.
You can use middlewares like so:
import {Store} from 'overstated';
class MyStore extends Store {
constructor () {
super ();
this.registerMiddleware ( this.middlewareLog );
}
middlewareLog = ( prevState ) => {
console.log ( 'prevState:', prevState );
console.log ( 'state:', this.state );
}
}
- ℹ️ You should use as few middlewares as possible, as they might get called very often.
- ℹ️ Updating the state from within a middleware also causes all middlewares affected by that to be called again, so you should avoid doing so if possible.
- ℹ️ Registering middlewares in parent stores (read more about them in the
compose
section) is discouraged, as they will also get called if a children store's state is updated. - ℹ️ It's good practice to name your middlewares
middleware*
, as that makes it clear that those are middlewares.
useStore
useStore
is a React hook for accessing a store's state and methods from within a component. You should always use useStore
, unless you can't use React hooks, instead of connect
, because it preserves your TypeScript types completely. This is its interface:
useStore ( storeClassOrInstance, selector: store => R, deps: ReadonlyArray<any> = [] ) R
- The first argument can be either a store class or a pre-instantiated store.
- The second argument is a selector (a function) which will receive the instance of the passed store and will return something, whatever will be returned by the selector will also be the return value of the
useStore
call. - The third argument is an optional array of dependencies. If your selector relies on external dependencies you must include all of them in the
deps
array, like you would when usinguseCallback
. By default the array of dependencies will be an empty array, so the first selector function will get reused indefinitely.
You can use it like so:
import CounterStore from './counter_store';
import {useStore} from 'overstated';
function Counter () {
const {value, increment, decrement} = useStore ( CounterStore, store => ({
value: store.state.value,
increment: store.increment,
decrement: store.decrement
}));
return (
<div>
<div>{value}</div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
}
- ℹ️ Every time the store's state changes Overstated will re-run the selector passed to
useStore
and compare the previously returned value with the current one, if nothing changed we stop there, if something changed the component gets re-rendered. - ℹ️ If you pass
useStore
a pre-instantiated store then you won't need to retrieve its methods this way, but instead you can access them directly (CounterStore.increment
), this has a couple of performance advantages:- The object returned by the selector will be slightly faster to compare against the previous one.
- If in a component you don't need to access any state at all from a selector, but only need to access its methods, then you can entirely avoid using
useStore
for that component.
- ℹ️ If you need to access state/methods from multiple stores just call
useStore
multiple times. - ℹ️ When using
compose
you may encounter the scenario where you need to access some methods from storeA
, but those methods actually access the state of storeB
, so passing storeA
touseStore
won't properly update the component because the changes in state will happen in storeB
. There are 2 ways to solve this issue:- Pass
compose
a store higher-up in the hierarchy that has both storeA
and storeB
below itself. - Pre-instantiate your stores, pass store
B
touseStore
, but then reference the pre-instantiated instance of storeA
inside your selector function. This solution, albeit slightly more performant, is discouraged because you'll have to reason about what stores' states your selector is actually accessing, and that's very error prone.
- Pass
connect
connect
is an alternative to useStore
. You should generally prefer useStore
over this as connect
doesn't fully preserve your TypeScript types (yet). This is its interface:
// USAGE 1
connect ( storeClassOrInstance )( ReactComponent );
// USAGE 2
connect ({
store?: storeClassOrInstance,
stores?: storeClassOrInstance,
selector?: Function
})( ReactComponent );
// USAGE 3
connect ({
store?: storeClassOrInstance,
stores?: storeClassOrInstance,
selector?: Function,
render?: ReactComponent
});
It is a decorator, so you can also call it like @connect ({ ... })
before a React's class component definition.
It can be used in either of those 3 ways:
- Pass it a store class or instance, and then pass it a React component.
- Pass it some options, and then pass it a React component.
- Pass it some options which include the
render
option, that will be used as the React component.
The options object supports the following options:
store
: a store class on instance.stores
: an array of store classes or instances.selector
: a selector which, similarly to howuseStore
's selector works, will be called with the following object:{ store, stores }
and will have to return the props to pass to the component.render
: a component to render. This property exists so that if you wish to you can define your React function component there and have everything neatly contained within a single object and cleanly indented.
compose
compose
allows you to set a parent <-> child
relationship between your stores, so that a parent store has access to its children and the children have access to their parent (and as a consequence to each other too). This is useful because as your app grows you might want to split your app's state and methods into multiple stores that can talk to each other.
compose ({
child1: StoreClassOrInstance1,
child2: StoreClassOrInstance2,
// ...
})( ParentStore )
You can use it like so:
import {compose, Store} from 'overstated';
class Foo extends Store<{value: number}, App> {
state: {
value: 123
}
setValue = ( value: number ) => {
return this.setState ({ value });
}
example = () => {
this.ctx.updateBar (); // Accessing the parent
this.ctx.bar.setValue ( 'Hi' ); // Accessing a sibling via the parent
}
}
class Bar extends Store<{value: string}, App> {
state: {
value: 'hello'
}
setValue = ( value: string ) => {
return this.setState ({ value });
}
example = () => {
this.ctx.updateFoo (); // Accessing the parent
this.ctx.foo.setValue ( 0 ); // Accessing a sibling via the parent
}
}
@compose ({
foo: Foo,
bar: Bar
})
class App extends Store<{}, undefined, {foo: Foo, bar: Bar}> {
updateFoo = () => {
return this.foo.setValue ( 10 ); // Accessing a sibling
}
updateBar = () => {
return this.bar.setValue ( 'hello there!' ); // Accessing a sibling
}
}
It works like this:
- Firt of all if you're using TypeScript the Store class accepts a couple more types: the first type, which we already encountered, describes the interface of the state, the second type describes the inferface of the parent store (if any) and the third stype describes the interface of the children stores (if any).
compose
will make sure that all stores classes/instances passed to it are accessible from the parent, so that if you tellcompose
to map theFoo
store to thefoo
property then you can access it viathis.foo
from the parent store.compose
will make sure that all child stores can access the parent (and as a consequence each other too) viathis.ctx
.- If the stores passed to
compose
aren't already instantiated they will be instantiated right when the parent store itself is instantiated. - Finally whenever a child store's state gets updated the parent's state will also be updated: the content of the state will be the same but the state itself will be a different object, this is done in order to ensure that if you connect to a parent store via
useStore
orconnect
your component will re-render properly also when the children stores get updated. For the same reason yourmiddlewares
registered in a parent store will also get called when the children's stores get updated.
- ℹ️ It's important to note that if you pass a parent store to
useStore
orconnect
then your component could potentially attempt to re-render even when state from other stores which it doesn't care about gets updated. To avoid this potentially significant performance penalty you should instead, whenever possible, pre-instantiate your parent store and then pass the child store you need directly (MyParentStore.foo
rather thanMyParentStore
) touseStore
orconnect
.
debug
debug
is a simple way to access your stores and their state from the the devtools and see at a glance when any store's state changes.
This is its interface:
debug ({
collapsed: false, // Whether the logged group should be collapsed
logNewStores: false, // Whether to log when a new store is instantiated
logStateDiffChanges: true, // Whether to log diffs (added, updated, removed) state changes
logStateFullChanges: true // Whether to log the previous and current state in their entirity
});
You can use it like so:
import {debug} from 'overstated';
debug ();
Once called debug
also defines a global object named OVERSTATED
, which you can access from the devtools. This is its interface:
window.OVERSTATED = {
stores: { ... }, // Access all stores
states: { ... }, // Access all states
log (): void // Log all states
};
You can use it like so:
OVERSTATED.stores.CounterStore.increment (); // If you want to call a method manually
OVERSTATED.states.CounterStore; // If you want to access a store's state quickly
OVERSTATED.log (); // If you want to see all your stores' states at once
ℹ️ It's important to call
debug
before rendering the app and before manually instantiating any stores.ℹ️ Make sure to enabled
debug
only during development.
Hooks
Hooks
provides a simple way to "hook" into Overstated's internal events. Each hook has the following interface:
class Hook {
subscribe ( callback: Function ): void
unsubscribe ( callback: Function ): void
}
subscribe
registers a function for being called every time that hook is triggered.unsubscribe
unregisters a previously registered function.
These are all the hooks currently available:
const Hooks = {
store: {
new: Hook // This hook is actually used to implement `debug`
}
};
You can use hooks like so:
import {Hooks} from 'overstated';
Hooks.store.new.subscribe ( store => console.log ( 'New store just instantiated:', store ) );
If you need some other hooks for your Overstated plugin let me know and I'll make sure to add it.
We currently don't have an official "Overstated DevTools Extension", but it would be super cool to have one. I'm thinking basically one could implement everything implemented by
debug
but in a panel accessible from the browser's devtools. Perhaps some other cool features could be implemented like time travel debugging, for restoring the app to a previous state in time. If somebody would like to implement this please do let me know! 😃
FAQ
Why not using Unstated?
As you might have noticed Overstated is modeled after Unstated, which I've been using in the past few months and mostly liking. But Unstated has a few problems:
- Subscribing to a store is cumbersone, our
useStore
is so much nicer, especially if you're using TypeScript. - It's impossible to centralize stores like we can do with
compose
, so except for the smallest apps you have to subscribe to multiple stores or deal with a massive single-file store. - There's no concept of
suspension
, so if you update the state within a loop enough times the app could basically crash because too many re-renders are triggered. - There's no way to define
middlewares
, and sometimes you want them. - There's no
debug
-like capabilities built-in. - There's no concept of a "selector" function, so your components always re-render even if they don't care about the particular bit of state that changed.
Some of these problems can be solved via third-party libraries, which me and others have written and are available here, but some things just can't be implemented in a performant manner when you're building on top of Unstated, for instance I ended up adding 5+ Higher-order-Components to each of my components that needed to subscribe to a store. And by implementing all this in the core you can do some nice things like skipping re-renders much earlier in the process.
In the end I decided that basically rewriting Unstated, but with performance in mind, more features and better TypeScript types, was the right option for me.
I don't think there's any reason to use Unstated over Overstated frankly. You can't even open an issue in Unstated's repository because the issues page is disabled.
Why not using Redux?
I'm not as familiar with Redux as I am with Unstated, but I think some reasons for using Overstated over Redux might be:
- If you don't know or like Redux already I think Overstated will be much easier to learn and use as we don't have "actions", "reducers", there's no big switch statement to write, nothing to "dispatch", no "payloads", nothing weird that needs to be done for asynchronous actions, no need to write TypeScript types for actions, we just call some methods and pass them arguments, just like you would normally do.
- I'm not sure how Redux handles actions dispatched from within actions, via
suspension
andautosuspend
we can make sure that that causes a single re-render of your app, maybe you can do the same with Redux too? Maybe you're not supposed to do that? I don't know.
But Redux is definitely a fine library, which has been battle-tested and which has an amazing ecosystem around it.
I guess ultimatelly the reason to pick one over the other might be a matter of personal preference.
How to write a very performant app?
In order to write a very performant app there's only one golden rule:
- Make sure that your components are only re-rendered when necessary, and make sure that each render is fast.
To break that down into smaller to digest tips, roughly ordered by importance:
- Mesure, measure and measure. Make you sure you know what the slowest parts of your apps actually are before attempting any optimizations.
- Always use a selector function.
- Make sure your selectors are fast.
- Use
autosuspend
to make sure that 1 method call causes 1 re-render. You may not always want this, but in general I think it's a good starting point. - Write smaller stores that contain less state, so that they each of them will trigger fewer re-renders.
- If you're using
compose
, pre-instantiate your parent store and pass the child store directly touseStore
orconnect
, so that your component won't attempt to re-render if other stores' states change. - Use as few middlewares as possible, there's probably another way to implement the same thing which doesn't involves middlewares at all.
- Make sure your middlewares are fast.
- Pre-instantiate your stores and use their methods directly rather than retrieving them via
useStore
orconnect
. - Always define your methods like this:
foo = () => {}
rather than like this:foo () {}
so that you will never have to callFunction#bind
on them when using them.
And in general make sure to also follow JavaScript/React's best practices with regards to performance.
Thanks
- @webstrand: for providing the trickiest TypeScript annotations we needed.
- @jamiebuilds: for writing Unstated, the first state management library that really resonated with me.
- The @facebook/react team: for developing React and hooks, which are wonderful.
- My mom too, since this thanks section is getting akwardly long anyway.
License
MIT © Fabio Spampinato