React state management library that's delightful to use, without sacrificing performance or scalability.
React state management library that's delightful to use, without sacrificing performance or scalability.
Provider
, define some initial state and some methods for updating it in a Store
and then access the state and methods you need in your components either via the useStore
hook or connect
.autosuspend
, debug
) is built-in.connect
to multiple stores with a single call, compose
multiple stores easily and use middlewares
.Check out the FAQ section for some commonly asked questions.
npm install --save overstated
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' )
);
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.
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:
state
object, which:
setState
method, which:
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 }) );
}
}
this.state.foo = 123
), always use setState
(this.setState ({ foo: 123 })
).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.foo = () => {}
rather than like this: foo () {}
so that you will never have to call Function#bind
on them when using them.return
the value returned by setState
, so that other methods can await
it if needed.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.
compose
section.The unsuspend
method allows you to unsuspend re-renders: if before calling unsuspend
a re-render has been prevented it will now be triggered.
compose
section.The isSuspended
method allows you to check if re-renders are currenly suspended.
ℹ️ If for instance you call suspend
3 times you should also call unsuspend
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 use autosuspend
, 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
or is
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 over methods
.
methodsExclude
is a regex, if your method's name matches that regex then it will not be autosuspended, has higher proprity over methods
and methodsInclude
.
propagateUp
checks whether parent stores (read more about them in the compose
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 the compose
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 the compose
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
and methodsExclude
optional regexes, so that you won't have to manually modify the methods
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 );
}
}
compose
section) is discouraged, as they will also get called if a children store's state is updated.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
useStore
call.deps
array, like you would when using useCallback
. 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>
);
}
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.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:
useStore
for that component.useStore
multiple times.compose
you may encounter the scenario where you need to access some methods from store A
, but those methods actually access the state of store B
, so passing store A
to useStore
won't properly update the component because the changes in state will happen in store B
. There are 2 ways to solve this issue:
compose
a store higher-up in the hierarchy that has both store A
and store B
below itself.B
to useStore
, but then reference the pre-instantiated instance of store A
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.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:
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 how useStore
'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:
compose
will make sure that all stores classes/instances passed to it are accessible from the parent, so that if you tell compose
to map the Foo
store to the foo
property then you can access it via this.foo
from the parent store.compose
will make sure that all child stores can access the parent (and as a consequence each other too) via this.ctx
.compose
aren't already instantiated they will be instantiated right when the parent store itself is instantiated.useStore
or connect
your component will re-render properly also when the children stores get updated. For the same reason your middlewares
registered in a parent store will also get called when the children's stores get updated.useStore
or connect
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 than MyParentStore
) to useStore
or connect
.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! ?
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:
useStore
is so much nicer, especially if you're using TypeScript.compose
, so except for the smallest apps you have to subscribe to multiple stores or deal with a massive single-file store.suspension
, so if you update the state within a loop enough times the app could basically crash because too many re-renders are triggered.middlewares
, and sometimes you want them.debug
-like capabilities built-in.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.
I'm not as familiar with Redux as I am with Unstated, but I think some reasons for using Overstated over Redux might be:
suspension
and autosuspend
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.
In order to write a very performant app there's only one golden rule:
To break that down into smaller to digest tips, roughly ordered by importance:
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.compose
, pre-instantiate your parent store and pass the child store directly to useStore
or connect
, so that your component won't attempt to re-render if other stores' states change.useStore
or connect
.foo = () => {}
rather than like this: foo () {}
so that you will never have to call Function#bind
on them when using them.And in general make sure to also follow JavaScript/React's best practices with regards to performance.
MIT © Fabio Spampinato