dai-shi / react-hooks-global-state

[NOT MAINTAINED] Simple global state for React with Hooks API without Context API

Home Page:https://www.npmjs.com/package/react-hooks-global-state

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

API design question

ivan-kleshnin opened this issue · comments

Hello Daishi. Trying to wrap my head around your library.

I'm curious why did you design the reducer API like this:

const initialState = { counter: 0 }; // initialState is not optional.
const { GlobalStateProvider, dispatch, useGlobalState } = createStore(reducer, initialState);

const Counter = () => {
  const [value] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
    </div>
  );
};

and not like this:

const initialState = { counter: 0 }; // initialState is not optional.
const { GlobalStateProvider, useGlobalState } = createStore(reducer, initialState);

const Counter = () => {
  const [value, dispatch] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
    </div>
  );
};

I'm not sure about all pros & cons, except that the second one looks more similar to basic setState option (which is a plus). Will appreciate your clarification.

Hi!
This is not exhaustive pros and cons, but let me explain.

If we go with the first API design, the dispatch is globally available in a file and you can define action functions outside of components. You can even define them in a separate file and import them. We don't need mapDispatchToProps technique like in react-redux.

const initialState = { counter: 0 }; // initialState is not optional.
const { GlobalStateProvider, dispatch, useGlobalState } = createStore(reducer, initialState);

const increment = () => dispatch({ type: 'increment' });
const decrement = () => dispatch({ type: 'decrement' });

const Counter = () => {
  const [value] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  );
};

Not much different if use button here, but suppose we use a custom MyButton extending React.PureComponent or with React.memo(), we probably want onClick function identity.
To keep the function identity with the second API design, we need to use useCallback().

import { useCallback } from 'react';

const initialState = { counter: 0 }; // initialState is not optional.
const { GlobalStateProvider, useGlobalState } = createStore(reducer, initialState);

const Counter = () => {
  const [value, dispatch] = useGlobalState('counter');
  const increment = useCallback(() => dispatch({ type: 'increment' }), []);
  const decrement = useCallback(() => dispatch({ type: 'decrement' }), []);
  return (
    <div>
      <span>Counter: {value}</span>
      <MyButton onClick={increment}>+1</MyButton>
      <MyButton onClick={decrement}>-1</MyButton>
    </div>
  );
};

If developers are used to memoization, this is trivial, but it may still introduce boilerplate code, which I would like to avoid.

The downside of the global dispatch is that you need to mock it when testing.
However, we still have useGlobalState globally and probably it won't change much.
There might be other differences, and I'd admit that I need to learn more about testing components with hooks.


Historically, this library was developed primarily for the setState style.

  const [value, update] = useGlobalState('counter');

So, the second value of useGlobalState is an update function.
This is still true even if we use the reducer style.
Although I don't recommend using update together with dispatch, it's technically possible.
(As long as you don't use the Redux DevTools.)

We could implement useGlobalStateDispatch, but that doesn't appeal much, does it?

const initialState = { counter: 0 }; // initialState is not optional.
const { GlobalStateProvider, useGlobalStateDispatch, useGlobalState } = createStore(reducer, initialState);

const Counter = () => {
  const [value] = useGlobalState('counter');
  const dispatch = useGlobalStateDispatch();
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
    </div>
  );
};

Great explanation, man. Honestly, I never used Redux (more than for a few demo cases), but your explanations make sense to me. Thank you!

One more topic I'd like to discuss, if you don't mind:

Dispatch vs Update

Comparing dispatch and update-based approaches...

With the following:

const Counter = () => {
  const [value, update] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={() => update(v => v + 1)}>+1</button>
      <button onClick={() => update(v => v - 1)}>-1</button>
    </div>
  );
};

we have pieces of logic incapsulated in JSX. They may be small enough to ignore or large enough to want to abstract away:

// actions.js
let actions = {
  increment: (v) => v + 1,
  decrement: (v) => v + 1,
}

// components/Counter.js
const Counter = () => {
  const [value, update] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={() => update(actions.increment)}>+1</button>
      <button onClick={() => update(actions.decrement)}>-1</button>
    </div>
  );
};
  1. Do you use the above approach yourself?

It may be simplified, if the update function is redesigned to return a callable:

// actions.js
let actions = {
  increment: (v) => v + 1,
  decrement: (v) => v + 1,
}

// components/Counter.js
const Counter = () => {
  const [value, update] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={update(actions.increment)}>+1</button>
      <button onClick={update(actions.decrement)}>-1</button>
    </div>
  );
};

function update(fn) {
  setState(fn)
}
// vs
function update(fn) {
  return function () { setState(fn) }
}

or it can be exposed as a third function, I dunno:

const [value, update, update2] = useGlobalState('counter')
  1. The benefit of both useReducer and the action-abstraction (mappers), shown above, is a separation between logic and templates which makes the logic easily testable. Of course, there are multiple approaches and personal prereferences, when it comes to testing. If ones favor E2E or high-level integration tests – maybe they don't this step.

  2. Now if we compare reducers and mappers, I believe the main benefit of dispatch is traceability. You can't make a comparable Redux Devtool analogy with update (enclosed functions arguments are invisible, function names are unreliable when you use currying, etc). The main drawback is the extra complexity.

Would like to know your opinion on 1), 2) and 3).

Dispatch vs Update

In general, if you would like to follow "action-abstraction (mappers)", I'd suggest to use dispatch.

So, I'd use dispatch in this scenario, but still you could use update. In this case, I would do like the following.

// actions.js
let actions = {
  increment: (update) => () => update((v) => v + 1),
  decrement: (update) => () => update((v) => v + 1),
}

// components/Counter.js
const Counter = () => {
  const [value, update] = useGlobalState('counter');
  return (
    <div>
      <span>Counter: {value}</span>
      <button onClick={actions.increment(update)}>+1</button>
      <button onClick={actions.decrement(update)}>-1</button>
    </div>
  );
};

You may not like this approach, though. Hm, maybe I wouldn't take this approach either.

Another option: <button onClick={applyAction(update, actions.increment)}>

Again, if you want to separate actions from components, just using dispatch is natural.

However, we don't know about the best practice about the separation of logic and JSX in our new "hooks" world.

What I could imagine is something like this:

// components/CounterContainer.js
const CounterContainer = () => {
  const [value, update] = useGlobalState('counter');
  const increment = useCallback(() => update(v => v + 1), []);
  const decrement = useCallback(() => update(v => v - 1), []);
  return <ConterPresentation value={value} increment={increment} decrement={decrement} />;
};

// components/CounterPresentation.js
const CounterPresentation = ({ value, increment, decrement }) => (
  <div>
    <span>Counter: {value}</span>
    <button onClick={increment}>+1</button>
    <button onClick={decrement}>-1</button>
  </div>
);

I actually like this one. It rather hides a global state in a container. It's more component-oriented approach. It seems to me to fit more with React philosophy.

Seems like it fits better with you to take the dispatch approach.

Like the previous example, the update approach is to hide updating states in a component, so the mindset is probably the other way around.


function update(fn) {
  return function () { setState(fn) }
}

This doesn't work if you need to take an argument like TextBox.
https://github.com/dai-shi/react-hooks-global-state/blob/master/examples/01_minimal/src/index.js#L28-L39

BTW, have you looked into the examples folder? Feedback appreciated.


Please correct me if I misunderstood some of your questions.

I actually like this one. It rather hides a global state in a container. It's more component-oriented approach. It seems to me to fit more with React philosophy.

Hmm, I have to disagree here. In my opinion state changing logic should be as simple and straightforward as possible. A function or an object of functions are fine. Reducer is already slightly overcomplicated and I don't see other benefit than a) being more familiar for Redux users b) being more trace/debug friendly. Component is an overkill i.m.o. To test a component you need a fake DOM (an integration test) which is very slow in comparison to pure unit tests you could have otherwise.

BTW, have you looked into the examples folder? Feedback appreciated.

No but I need to. Thanks for the suggestion – I didn't notice them.

This doesn't work if you need to take an argument like TextBox.

Yeah, but I guess something like this can work:

function update(fn, ...args) {
  return function () { setState(fn.bind(null, args)) }
}

I believe Hyperapp v1 had the API like this, may be wrong though.

Hmm, I have to disagree here.

Fair enough. Testing pure functions is always easier.

Yeah, but I guess something like this can work:

I see what you mean. I think we need arguments for the returning function.
What's good about hooks is that you can extend them by yourself based on primitives.

const { GlobalStateProvider, useGlobalState: useGlobalStateOrig } = createGlobalState(initialState);

function useGlobalState(name) {
  const [state, setState] = useGlobalStateOrig(name);
  function update(fn) {
    return function(...args) { setState(fn(...args)); }
  }
  return [state, update];
}

What's good about hooks is that you can extend them by yourself based on primitives.

True story 👍 Need to experiment with that.

Do you plan to go in the direction of lensing:

useGlobalState('person');
// vs
useGlobalState('person.address');
// vs
useGlobalState(lensFrom(['person', 'address', ...]);

And yes, your examples are great! Gonna give this library a serious trial :)

This library is pretty much optimized for top-level selector (by observedBits and type inference).
My recommendation for deep property value selection is something like that in examples/11_deep.

Nevertheless, you could build deep selector based on primitives.
A naive implementation to get a deep value would be:

const useGlobalStateByPath = (path) => {
  path = lodash.toPath(path);
  const name = path.shift();
  const [value] = useGlobalState(name);
  return [lodash.get(value, path)];
};

(You need update too for real lensing.)

You can accomplish global reducers/state without relying on the Context API or "global" dispatch functions:

https://github.com/jacob-ebey/react-hook-utils

@jacob-ebey Yeah, my original motivation and implementation was also eliminating context, and then eventually I came back to use context which seems to work better in concurrent mode in the future. I also wanted to make use of observedBits for performance.

Can you elaborate on what works better in concurrent mode?

The concurrent mode is still not fixed (it will change), and there could still be something I misunderstand. Anyway, here's a reference:
https://blog.isquaredsoftware.com/2018/11/react-redux-history-implementation/

In v6:

The Redux store state is put into an instance of the new createContext API
There is only one store subscriber: the component
This has all kinds of ripple effects across the implementation.

It's fair to ask why we chose to change this aspect. We certainly could have put the store instance into createContext, but there's several reasons why it made sense to put the store state into context instead.

The largest reason is to improve compatibility with "concurrent React", because the entire tree will see a single consistent state value. The very short explanation on this is that React's "time-slicing" and "Suspense" features can potentially cause problems when used with external synchronous state management tools. As one example, Andrew Clark has described "tearing" as a possible problem, where different parts of the component tree see different values during the same component tree re-render pass. By passing down the current state via context, we can ensure that the entire tree sees the same state value, because React takes care of that for us.

(This then turned out to be too early as there's not yet a way to bail out with useContext... ref: reduxjs/react-redux#1177)


On the other hand, this project is fine with context as long as we can use observedBits. ref #5

Thanks for the link. Does "tearing" here refer to the visual representation of the vdom being out of sync current tree?

I too am not sure, but probably it's about different parts in the single vdom tree.

You can learn some more about it here. I think I follow the technique described there for my other library that uses subscriptions. I can't say for sure as the description is not very specific to hooks (at least for me).

Closing this issue. Feel free to open a new one.