use-stash

2.6.0 • Public • Published

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.

build status npm version

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

Dependencies (2)

Dev Dependencies (27)

Package Sidebar

Install

npm i use-stash

Weekly Downloads

1

Version

2.6.0

License

MIT

Unpacked Size

51.4 kB

Total Files

12

Last publish

Collaborators

  • a.kuzko