The ultimate useStore
implementation. The power and simplicity of useState
, but with persistence, global key-based synchronization without context, speed and reference optimization, safety checks, and other cool stuff.
Ever dreamed of such of feature but couldn't come up with a 100% satisfying solution ? Well, this package is for you.
- provide you with a reliable key-based
useState
-like feature calleduseStore
, - with auto-sync with other
useStore
calls sharing the same key, - without context,
- with persistence across page reloads and browser sessions (or not),
- with configurable safety asserts on deserialization and default fallbacks,
- with no unnecessary rerender, ever,
- with SSR capacities (see FAQ),
- with simplicity and cool local / global configuration options,
- with zero dependency (other than React of course),
- with very little extra bundle size (+ 2.6 KB (4 times less than this very readme))
yarn add react-stored
Say you have the two following components far away from one another in the tree :
import React from "react";
import { useStore } from "react-stored";
function FirstComponent() {
const [counter, setCounter] = useStore("counter", 0);
return (
<>
<h1>Counter : {counter}</h1>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</>
);
}
import React from "react";
import { useStore } from "react-stored";
function SecondComponent() {
const [counter, setCounter] = useStore("counter", 0);
return (
<>
<h1>Counter : {counter}</h1>
<button onClick={() => setCounter(0)}>Reset</button>
</>
);
}
Since they share the same key ('counter'
), they actually seemlessly share the same value and keep one another in sync no matter how hard you try to unsync them. Even better : if you refresh the page, nothing changes. The values are persistent. You better like them, because they ain't going anywhere unless you change the key or... the key prefix (see config).
The second argument to useStore
, the number 0
in this case, represents the default counter value, as no persistent save can be found the first time around.
This is the cornerstone of this package. It 'connects' you to a specific store, identified by key, and returns the value at that location as well as an update function. Its overall feel mimics useState
. It also listens to any outside change, and rerenders accordingly to keep all parts of your UI in sync.
It can take up to 3 arguments (only the key is required) :
const [value, setValue] = useStore(key, defaultValue, assertFunction);
key
: Any string.defaultValue
(optional) : The value affected by default to the store and returned byuseStore
when no previous save is found. This could be any JSON value (and even more).assertFunction
(optional) : On initial render, or when any ofuseStore
's parameters changes, the previous save passes through this function and has to returntrue
. If it returnsfalse
or throws an error,defaultValue
will be used and overwrite the save. This can be very handy, for example to prevent the hydration ofuseStore
with ill-formed or outdated JSON. I would usually use ajv in places like these.
Just like most hooks, useStore
relies on object identity to optimize internal recomputations. If your defaultValue
is an object or an array, please use useRef
or useMemo
to keep the same reference as long as possible :
const init = useMemo(() => ({ x: props.x, y: 0 }), [props.x]);
const [coord, setCoord] = useStore("coord", init);
Similarly, use useCallback
for the assert function :
const assert = useCallback(state => ajv.validate(props.model, state), [
props.model
]);
const [state, setState] = useStore("my-state", null, assert);
Better (!) : Whenever possible, set your defaultValue
and assertFunction
outside the render tree using addSchema
. This way, you don't have to worry about reference optimization. Plus, the separation between configuration and usage makes your code cleaner.
Like useState
, the update function can take a value, or a function taking the old value as an argument and returning the new one. If you update the store to the same value as the current one (using ===
for the comparison), no update is actually triggered, thus preventing useless rerenders.
const [counter, setCounter] = useStore('counter')
<button onClick={() => setCounter(counter + 1)}>
Increment
</button>
// Equivalent to :
const setCounter = useStore('counter')[1]
<button onClick={() => setCounter(counter => counter + 1)}>
Increment
</button>
The identity of this update function is preserved as long as the key
stays the same.
This configuration function allows you to set default- default values and default assert functions to certain keys or key patterns outside of your React tree, typically in index.js
before your ReactDOM.render
. If you don't rely on props to set default values and assert functions, you shouldn't set them at component-level and addSchema
should be your primary configuration choice.
It takes the same arguments as useStore
except the key can be a regexp. If it is, then all keys matching the regexp will use the given configuration.
import React from "react";
import ReactDOM from "react-dom";
import { addSchema } from "react-stored";
import Ajv from "ajv";
import App from "./App";
addSchema("counter", 0, counter => counter < 100);
// Any invocation of 'counter' will now use 0 as its default value, and ensure
// that any retrieved save is smaller than 100. If not, 0 will be used instead.
addSchema(/coord-v\d+/, { x: 0, y: 4 });
// Any invocation of 'coord-v1', 'coord-v43', 'coord-v9987', etc. will use the
// given object as its default value.
const isValidArray = new Ajv().compile({
type: "array",
items: { type: "string" }
});
addSchema(/array-[0-9A-F]{2}/, [], isValidArray);
// Any invocation from 'array-00' to 'array-FF' will be initialized with an
// empty array. Any previous save should be an array of strings, otherwise
// it will be overwritten with an empty array.
ReactDOM.render(<App />, document.getElementById("root"));
With this function, you can tweak some general stuff. It has to be called outside of the React structure, before any useStore
call, so usually somewhere in your index.js
before your ReactDOM.render
.
import React from "react";
import ReactDOM from "react-dom";
import { config } from "react-stored";
import App from "./App";
// Below are the DEFAULT settings, it is pointless
// to set them explicitly to these values :
config({
// IMPORTANT : A seemless prefix to ALL your keys, this has to be specific
// to your app :
keyPrefix: "",
// The storage to be used (could be replaced with an in-memory alternative,
// sessionStorage, a cookie-based storage, etc. See FAQ) :
storage: window.localStorage,
// When true, real-time auto-sync is extended across all browser tabs
// sharing the same origin :
crossTab: false,
// A function that should transform JSON into a string :
serialize: JSON.stringify,
// A function that should transform a string into JSON :
deserialize: JSON.parse
});
ReactDOM.render(<App />, document.getElementById("root"));
Unless you have some very specific use cases, the keyPrefix
is really the only important part to configure. You set it once, and everything stored or retrieved from the storage will use that prefix in addition to the keys used in your components. All this happens of course seemlessly, you don't have to think about it.
Yes, localStorage
is compartmentalized by domain but you could have several apps by domain. It's just a good habit to set a keyPrefix
that is specific to your app. It defines a namespace.
Also, imagine several customers already tested your app and have their local copy of the store. Now say you wanna change the JSON structure because of some new requirements. You could just set keyPrefix
to the current version of the app, thus preventing any hydration of outdated JSON saves.
Here is typically what my index.js
looks like :
import React from "react";
import ReactDOM from "react-dom";
import { config } from "react-stored";
import App from "./App";
config({
keyPrefix: "my-app-v2.4.1-"
});
ReactDOM.render(<App />, document.getElementById("root"));
You can also replace all your addSchema
calls with a single array using the config
function :
config({
keyPrefix: "my-app-v4",
schemas: [
{
key: "counter",
init: 0,
assert: counter => counter < 100
},
{
key: /coord-v\d+/,
init: { x: 1, y: 0 }
}
]
});
This gives you the possibility to passively read the content of your store outside of any component. This was useful for me when I needed to pass a stored token to some server requests using Apollo links. It takes only one argument, the key, and returns the corresponding JSON.
Here is what's going on everytime you invoke useStore
on a specific key :
- If a previous save is found in the storage for that key, it is used. If not :
- If a default value is set locally (as an argument of
useStore
), it is used. If not : - If a default value is set globally (as an argument of
addSchema
), it is used. If not : null
will be used.
Things are easier with the assert function : if none could be found for a specific key (not locally nor globally), the functionality is simply discarded and hydration of previous saves goes without any checks.
Since you have absolute control over the actual storage being used in the background (see config
), you can use your own custom in-memory version (it just has to implement getItem
and setItem
). There are plenty of npm packages with such alternatives. I personally like memorystorage :
import { config } from "react-stored";
import MemoryStorage from "memorystorage";
config({
keyPrefix: "whatever",
storage: new MemoryStorage()
});
Dead simple. Again, you have to tweak your config
to use a universal version of localStorage
(i.e. with in-memory fallback on server-side).
import { config } from "react-stored";
import MemoryStorage from "memorystorage";
config({
keyPrefix: "whatever",
storage:
typeof window === "undefined" ? new MemoryStorage() : window.localStorage
});
This is possible with a careful use of serialize-javascript, eval
and a bit of configuration :
import { config } from "react-stored";
import serialize from "serialize-javascript";
config({
serialize,
deserialize: str => eval(`(${str})`)
});
I look forward to any suggestion, question or bug report. Please use GitHub's issue tracker.