/reductive

Redux in Reason

Primary LanguageReasonMIT LicenseMIT

Reductive

Reductive, like redux, is a predictable state container for Reason applications. Its scope goes beyond that of managing state for a single component, and concerns the application as a whole.

Use case

For simpler use cases, it might be sufficient with the useReducer hook to manage state on a component level, or combining this approach with react context to allow components deeper in the tree receive updates via useContext hook. For more, see you might not need redux, especially so in a language which provides good enough construction blocks out of the box.

The mentioned approach, however, doesn't allow to subscribe to only part of the global state, resulting in all subscribed components re-render any time something in the state changes (even if they are not interested in particular updates). This is a known issue and occurs since there is no bail-out mechanism inside useContext, see long github thread for a really deep insight.

This might not be a problem for many applications, or might become a one for the others. reductive exposes useSelector hook, that makes sure only the components interested in a particular update are re-rendered, and not the rest.

Installation

Install via:

$ npm install --save reductive

and add it to bsconfig.json:

"bs-dependencies": ["reductive"]

Hooks

ReasonReact version 0.7.0 has added support for react hooks and reductive now includes a set of hooks to subscribe to the store and dispatch actions. With the new hooks there is no need to use Lenses that wrap components, which results in simpler and cleaner code with a flat component tree. Moreover, the new hooks API is safe to use in concurrent mode.

The Lense API is still available, since there is support for the old jsx and reducer style components, but is marked as deprecated, since the old jsx syntax is also deprecated in the reason-react docs. The preferred way of using reductive is via the new hooks API.

Requirements

The new hooks API is built on top of the existing react hooks. In order to use hooks in ReasonReact, you have to use the new jsx syntax for ReasonReact components and ^5.0.4 or ^6.0.1 of bs-platform.

New projects will use the latest jsx version by default at the application level by having "react-jsx": 3 in bsconfig.json. Existing projects can be gradually converted using [@bs.config {jsx: 3}] to enable the new version at the file level.

Setup store and context provider

The new hooks API makes use of react context to make the store available to all nested components. You will need to create a store, implement a module with context provider and hooks, and render the provider with the created store at the top of the component tree.

First, define the state and action types and reducer for your application, and create a store:

type appState = { counter: int };
type appAction =
  | Increment
  | Decrement;

let appReducer = (state, action) => ...;
let appStore =
  Reductive.Store.create(
    ~reducer=appReducer,
    ~preloadedState={counter: 0},
    (),
  );

Then create a customized version of the context and hooks for your application:

module AppStore = {
  include ReductiveContext.Make({
    type action = appAction;
    type state = appState;
  });
};

This will create a "typed" version of the store context and hooks with the action and state types specific to your application. If you are curious, ReductiveContext.Make is called a functor, which is a module that acts as a function, and can be used to make custom versions of a module for different data structures.

Finally, use the provider from AppStore when rendering your root component passing in the created store:

ReactDOMRe.renderToElementWithId(
  <AppStore.Provider store=appStore> <Root /> </AppStore.Provider>,
  "root",
);

From now on you will access the hooks from your AppStore module, like AppStore.useSelector and AppStore.useDispatch.

useSelector

Subscribes to changes to a selected portion of the store state, specified by a selector function. The selector function accepts the whole store state and runs whenever an action is dispatched or the component renders (for some other reason than store updates).

useSelector is built on top of the useSubscription hook, which is safe to use in the concurrent mode.

Selector function

The selector function is required to have a stable reference in order to avoid infinite re-renders. The easiest to achieve this is to declare it outside of the component,

// declare selector outside of the component
let userSelector = state => state.user;

// in the component
let user = AppStore.useSelector(userSelector);

or memoize with useCallback if it depends on props, state or anything else accessible only inside of the component:

[@react.component]
let make = (~id) => {
  let productSelector =
    React.useCallback1(
      state => state.products->Belt.List.keep(p => p.id === id),
      [|id|],
    );
  let product = AppStore.useSelector(productSelector);
  ...
};

Re-renders

useSelector relies on useState under the hood and therefore allows to bail-out of re-render similar to how useState works, which will compare by value for primitive types, and by reference for objects.

If the selector function returns an object, it won't cause a re-render only if the new object has the same reference as the previous one, and returning a new object every time will always cause a re-render. For example,

let selector = state => {email: state.user.email, cart: state.shop.cart};

// in the component
let selectedState = AppStore.useSelector(selector);

will cause the component to re-render every time an action is dispatched, regardless of whether user or shop have changed, since running the selector function will create a new object every time it is called. To prevent those re-renders, it is recommended to have multiple calls to useSelector, one per each individual field:

let emailSelector = state => state.user.email;
let cartSelector = state => cart: state.shop.cart;

// in the component
let email = AppStore.useSelector(emailSelector);
let cart = AppStore.useSelector(cartSelector);

This helps to make sure the component re-renders only when either email or cart on the store state changes.

This is different from how mapStateToProps, if you are used to dealing with the traditional redux flow, since mapStateToProps will compare individual fields of the returned object.

useDispatch

Returns the dispatch function from the store:

let dispatch = AppStore.useDispatch();
...
dispatch(Increment);

useStore

This hook returns a reference to the store that was passed in to the <Provider> component.

This hook should probably not be used frequently. Prefer useSelector() as your primary choice. However, this may be useful for less common scenarios that do require access to the store, such as replacing reducers.

Requirements

A recent release of Node LTS should be sufficient.

Examples

See the examples directory for several working examples using reductive. The basic example is console logging only. The immutable example may be broken due to an API incompatibility. The render example demonstrates the effectiveness of the hooks, in that only the components whose state has changed will be re-rendered; turn on the "highlight updates" option in React DevTools to see the effect. The todomvc example shows the use of useReducer along with reductive. While the todomvc example looks a lot like those of the todomvc project, it does not conform to the todomvc application specification, instead focusing on demonstrating the usefulness of reductive.

Running the Examples

Start by cloning this repository, then get everything installed and built:

$ npm install
$ npm run build
$ npx webpack

You can then open any of the HTML files in the test folder within your browser.

Comparisons with Redux

Actions

Redux actions are implemented as plain JS objects. JS objects can be a bit too flexible, and many Redux users rely on standardized shapes for their actions to make sure that middlewares and third party libraries work with them. Reason has language-level support for composing a set of data types which many functions can operate over. They are called variants and you can see how they are used here.

Action Creators

Redux action creators are used as composable ways of generating plain JS objects that match the schema of a specific action. In Reason, the type system does a lot of this work for you, so you can reliably put action types directly in your code and know that things will Just Work™. This has an added performance advantage of shifting that function call to compile time.

Enhancer Composition

Much of the power of redux comes from having a unified API for writing and using middleware or enhancers. They're typically wired together behind the scenes in the applyMiddlewares function. This function doesn't exist in Reductive, because Reason has language-level support for function composition with operators like |> and @@. You can see some example usage here and some of the deprecation messages to get a better understanding of typical usage.

The Source

The code behind Reductive is incredibly simple. The first 40 lines include the entire re-implementation of redux. The next ~40 lines are a re-implementation of the react-redux library (without the higher-order component connect style implementation). The code is short and relatively simple to follow.