use-stash
v2.6.0
Published
React hooks and HOCs for app-wide data access and manipulation
Downloads
5
Maintainers
Readme
React Stash Hooks
React hooks for app-wide data access and manipulation via actions. Minimalistic
and easy-to-use solution inspired by redux
family of projects.
Installation
npm install --save use-stash
Usage
1. Define Namespace(s)
Both application data and actions to manipulate it are organized in namespaces.
Each namespace is defined/initialized via defStash
function:
import { defStash } from "use-stash";
const initialData = {
items: [],
details: {}
};
defStash("todos", initialData, ({defAction, reduce}) => {
defAction("getTodos", () => {
fetch("/api/todos")
.then(response => response.json())
.then(items => reduce(data => ({...data, items})));
});
defAction("getTodo", (id) => {
fetch(`/api/todos/${id}`)
.then(response => response.json())
.then(details => reduce(data => ({...data, details})));
});
});
2. Import Stash Namespace Definitions
Don't forget to import all definitions on app initialization somewhere in your entry. Like so:
import "stash/todos";
import "stash/projects";
// ...
3. Use Data and Actions
use-stash
provides following hooks for you to use
useData(path)
Returns a data object specified by path
. The simplest value of path
is
namespace name itself. But it can also represent deeply nested value
(see Granular Data Access section bellow). Whenever this value gets
updated, your component will be updated as well.
Usage example is provided in useActions section bellow.
useActions(namespace)
Returns object with all actions defined for specified namespace
. This actions
can (and should) be used for all data manipulations.
Example:
import { useEffect } from "react";
import { useData, useActions } from "use-stash";
function TodosList() {
const {items} = useData("todos");
const {getTodos} = useActions("todos");
useEffect(() => {
getTodos();
}, []);
return (
// render list of todos
);
}
useStash(namespace)
Returns two-object array of namespace data and actions.
Example:
import { useEffect } from "react";
import { useStash } from "use-stash";
function TodoItem({id}) {
const [{details}, {getTodo}] = useStash("todos");
useEffect(() => {
getTodo(id);
}, [id]);
return (
// render details for single item
);
}
Stash instance API
When defining a new stash namespace, corresponding Stash
instance is passed to
setup function, ready for you to destructure it for convenient usage. Each
instance of Stash
is bound to specific namespace and has following
properties/methods:
namespace
Returns name of stash namespace. On definition, corresponds to first argument
that is passed to defStash
function.
defAction(name, fn)
Defines an action identified by name
under Stash's namespace that can then be
used via useActions
/ useStash
hooks. fn
is the function that represents
a body of the action.
reduce([descriptor], fn)
Updates data related to Stash namespace. Mandatory fn
reducer function should
take a single argument - current Stash data and return a new data. There should
be no objects updated in-place. Optional descriptor
parameter can provide
auxiliary information on what reducer logic is about. It is not required, but
may be consumed by mixins, such as logger mixin (see bellow). Despite the
fact it is optional, it comes as first argument due to better readability
when given.
Examples:
// no descriptor
defAction("addItem", (item) => {
reduce(data => [...data, item]);
});
// simple string descriptor
defAction("addItem", (item) => {
reduce("addItem", data => [...data, item]);
});
// array descriptor with additional data
defAction("addItem", (item) => {
reduce(["addItem", item], data => [...data, item]);
});
get([path])
Returns namespace-scope data at specified path
. Uses get-lookup
for path resolution. If path
is not provided, returns namespace data object
itself.
callAction(actionName, ...actionArgs)
Calls an action actionName
, passing actionArgs
to the function that was
used for this action definition via defAction
function.
ns(namespace)
Returns a Stash
instance for other namespace. This instance can be used for
all sorts of things - gettings necessary data, calling actions, even reducing
(changing) it's data, etc.
mixin(mixin)
Adds a mixin (see mixins section bellow), scoped to this stash namespace.
mixout(mixin)
Removes mixin from list of mixins applied for this stash namespace. Can be used, for instance, to opt-out mixin that was globally applied.
Common Use-Cases
Granular Data Access
Usually it may be much more efficient to use only small piece of data stored
in namespace. This way your component will be re-rendered only if that small piece
gets changed. To do so, you simply have to pass full path to the object you
are interested in to useData
hook call. use-stash
uses
get-lookup
package for fetching
deeply nested values. For example:
function ItemStatus({id}) {
const status = useData(`todos.list.items.{id:${id}}.status`);
return <div>{ status.toUpperCase() }</div>;
}
This component will be re-rendered only if status of item with corresponding
value of id
property in todos.list.items
array changes.
Also, as can be seen from the example above, it is OK to use dynamic data paths, i.e. the ones that depend on changeable props or state values.
Data Mapping on Access
In some scenarios, even when subscribed on a subset of data, there might be
need to extract some special value based on it, and re-render component only
when this value changes. For instance, one may have a TodoIndicator
component
that should render different value based on if there is any incomplete todo.
Thus, no matter if name of any todo changes, and if list itself changes,
that should not affect such component. To achieve this behavior, useData
hook
accepts a mapper function as it's second argument:
function TodoIndicator() {
const allDone = useData("todos.list.items", (items) => {
return items.every(item => item.status === "completed");
});
// ...
}
For even more complicated scenarios, where mapping function returns new identities every time (such as arrays or objects), one may pass comparator function as third argument:
import isEqual from "lodash/isEqual";
function TodoNames() {
const names = useData("todos.list.items", (items) => {
return items.map(item => item.name);
}, isEqual);
// ...
}
Accessing Data in Actions
You can use get
method of Stash
instance to access data of corresponding namespace:
defStash("todos", initialState, ({defAction, reduce, get}) => {
defAction("removeTodo", (index) => {
const id = get(`list.${index}.id`);
reduce((data) => {
return {...data, list: data.list.filter(item => item.id !== id)};
});
});
});
Cross-namespace Interaction
You can use ns
method to access Stash
instance of another namespace. It can be
used for fetching data from other namespaces, reducing it and even calling actions
defined in other namespaces:
defStash("todos", initialState, ({defAction, reduce, get, ns}) => {
defAction("removeTodo", (i) => {
const item = get("list")[i];
const username = ns("session").get("name");
reduce((data) => {
const list = [...data.list];
list.splice(i, 1);
return {...data, list};
});
ns("logs").reduce((data) => {
return [...data, `${username} removed item "${item.title}"`];
});
// if there is "addEntry" action defined in "logs" namespace, it can
// be called via
ns("logs").callAction("addEntry", `${username} removed item "${item.title}"`);
});
});
Mixins
Mixins are the way to add custom functionality to the one use-stash
provides.
In essence, mixins are simple functions that take stash
instance as first
mandatory argument and return an object with overloaded stash props. Any
arguments passed to mixin
function when applying mixin will be also passed to
mixin function itself.
Mixins can be global, i.e. ones that apply to all defined namespaces and local, applied individually by each namespace.
For instance, if we want to have a mixin that makes stash data available for
inspection at browser's console (via window
object), it may look like this:
window.appData = {};
export default function inspector(stash) {
const {namespace, init, reduce, get} = stash;
return {
init(data) {
window.appData[namespace] = data;
init(data);
},
reduce(...args) {
reduce(...args);
window.appData[namespace] = get();
}
};
}
And then, to add this mixin globally, i.e. to apply it to every defined stash namespace, we need a single function call:
import { mixin } from "use-stash";
mixin(inspector);
To apply mixin only to specific stash namespace, we can use Stash#mixin
function
available on stash namespace definition, i.e.:
defStash("inspectedStash", initial, ({mixin}) => {
const {defAction, reduce} = mixin(inspector);
// rest of definitions
});
It is important to notice here that methods used for stash namespace definition
are destructurized from the result of mixin
function call, i.e. after
mixin has been applied.
It is also possible to exclude mixin for single namespace if it has been globally
applied before. For this you can use Stash#mixout
function:
defStash("isolatedStash", initial, ({mixout}) => {
const {defAction, reduce} = mixout(inspector);
// rest of definitions
});
Just like with local mixin
function, methods used for stash namespace definition
should be destructurized from mixout
function call result.
mixins/logger
Provided out-of-the-box, logger
mixin function adds logging support to stash
action calls and data reducing in colorful and readable way. It also makes use
of descriptor string/object that can be passed as first argument of reduce
function.
By default, it colors logs for each namespace in it's own color (cycling through
presets), but colors for each namespace can be set up manually via colors
configuration option:
import { mixin } from "use-stash";
import { logger } from "use-stash/mixins";
if (__DEV__) {
mixin(logger, {
colors: {
todos: "blue"
}
});
}
And later on, for todos
namespace we define, we can take advantage of having
reducer's descriptor logged:
defStash("todos", initialData, ({defAction, reduce}) => {
defAction("getTodos", () => {
fetch("/api/todos")
.then(response => response.json())
.then(items => reduce("getTodosSuccess", data => ({...data, items})));
});
defAction("getTodo", (id) => {
fetch(`/api/todos/${id}`)
.then(response => response.json())
.then((details) => {
reduce(["getTodoSuccess", details], data => ({...data, details}));
// ^ this is equivalent of:
// reduce("getTodoSuccess", data => ({...data, details}), [details]);
});
});
});
It is also possible to prevent certain actions and reductions to be logged,
in case if they happen very frequently and you don't want them to spam console's
output. Such actions and reducers should be specified in the except
mixin option:
mixin(logger, {
except: ["todos.toggleTodo"]
});
mixins/actionReducer
Generic stash reducer functions are not bound to actions they have originated
due to async nature of data update flow (they only keep track of the latest action
being invoked). Since main purpose of use-stash
is productivity and simplicity,
for easier development and logging it is possible to use per-action reducer functions.
Such reducers will generate descriptors corresponding to action they are bound to:
import { mixin } from "use-stash";
import { actionReducer } from "use-stash/mixins";
mixin(actionReducer);
Make sure to add this mixin after adding logger
mixin (see above). And then
you can have:
defStash("todos", initialData, ({defAction}) => {
defAction("getTodos", () => (reduce) => {
fetch("/api/todos")
.then(response => response.json())
.then(items => reduce(data => ({...data, items})));
});
defAction("getTodo", (id) => (reduce) => {
fetch(`/api/todos/${id}`)
.then(response => response.json())
.then((details) => {
reduce.success(data => ({...data, details}), [details]);
// ^ the last optional array argument specifies additional
// data to be logged
});
});
});
Bellow you can see examples of what action reducer function correspond to:
defStash("todos", ({defAction, reduce: stashReduce}) => {
defAction("getTodo", (id) => (reduce) => {
// ...
reduce(() => data); // stashReduce("getTodo", () => data);
reduce(() => data, [data]); // stashReduce(["getTodo", data], () => data);
reduce.success(() => data); // stashReduce("getTodoSuccess", () => data);
reduce.success(() => data, [data]) // stashReduce(["getTodoSuccess", data], () => data);
reduce.failure(() => ({})); // stashReduce("getTodoFailure", () => ({}));
});
});
HOCs for Class Components
For hook-less class-based React components you can use HOC-based approach. For this,
use-stash
provides 3 helper functions, similar to hooks: withData
, withActions
and withStash
. The latter mixes functionality of the former two.
withData(dataMapping)
Allows to pass stash data to connected component's props. dataMapping
is a plain
object, whose keys are prop names and values are paths to data that component is
interested in.
Example:
const HocPage = withData({
username: "session.name",
todos: "todos.list.items",
logEntries: "logs"
})(Page);
In this example, underlying Page
component will receive username
, todos
and logEntries
props with values obtained from 3 different stash namespaces.
withActions(actionsMapping)
Allows to pass stash actions to connected component's props. actionsMapping
is a plain object, whose keys are prop names and values are strings that specify
stash namespace and action name, separated by dot.
Example:
const HocLayout = withActions({
fetchSession: "session.getSession"
})(Layout);
In this example, underlying Layout
component will receive fetchSession
prop,
which is a function corresponding to getSession
action of session
namespace.
withStash(dataMapping, actionsMapping)
Allows to pass both data and actions to connected component. A combination of
withData
and withActions
.
Example:
const HocPage = withStash({
username: "session.name",
todos: "todos.list.items",
logEntries: "logs"
}, {
getItems: "todos.getItems",
addItem: "todos.addItem"
})(Page);
Keep in mind that under the hood HOC wrappers are functional hook-based components, i.e. you still need React 16.8+ to use HOC helpers.
Hints and Tips
Use update-js
and update-js/fp
packages
To keep action definitions thin, clean and simple, it is advised to use
update-js
and/or
update-js/fp
packages
which can drastically reduce complexity of your data reducers.
For example, imagine that we have following initial data structure:
const initialData = {
list: {
loading: false,
items: [],
pagination: {}
},
// ...
};
Each item in the list identified by id
property and has checked
property
that can be changed via action.
And we want to define couple of actions: one that loads a list, toggling it's
loading
flag, and other for toggling checked
property of specific item in
the list.
With update-js
it can look like this:
import { defStash } from "use-stash";
import update from "update-js";
defStash("todos", initialData, ({defAction, reduce}) => {
defAction("getTodos", () => {
reduce(data => update(data, "list.loading", true));
fetch("/api/todos")
.then(response => response.json())
.then(items => {
reduce(data => update(data, {
"list.loading": false,
"list.items": items
}));
});
});
defAction("toggleChecked", (id) => {
reduce(data => update.with(data, `list.items.{id:${id}}.checked`, checked => !checked));
});
});
Or the same example with update-js/fp
package looks even shorter:
import { defStash } from "use-stash";
import update from "update-js/fp";
defStash("todos", initialData, ({defAction, reduce}) => {
defAction("getTodos", () => {
reduce(update("list.loading", true));
fetch("/api/todos")
.then(response => response.json())
.then(items => {
reduce(update({
"list.loading": false,
"list.items": items
}));
});
});
defAction("toggleChecked", (id) => {
reduce(update.with(`list.items.{id:${id}}.checked`, checked => !checked));
});
});
License
MIT