dai-shi/react-tracked

Comparison with related projects

dai-shi opened this issue · 73 comments

Context value Using subscriptions Optimization for rendering big object Dependencies Package size
react-tracked state-based object Yes *1 Proxy-based tracking No 1.5kB
constate state-based object No No (should use multiple contexts) No 329B
unstated-next state-based object No No (should use multiple contexts) No 362B
zustand N/A Yes Selector function No 742B
react-sweet-state state-based object Yes *3 Selector function No 4.5kB
storeon store Yes state names No 337B
react-hooks-global-state state object No *2 state names No 913B
react-redux (hooks) store Yes Selector function Redux 5.6kB
reactive-react-redux state-based object Yes *1 Proxy-based tracking Redux 1.4kB
easy-peasy store Yes Selector function Redux, immer, and so on 9.5kB
mobx-react-lite mutable state object No *4 Proxy-based tracking MobX 1.7kB
hookstate N/A Yes Proxy-based tracking No 2.6kB
  • *1 Stops context propagation by calculateChangedBits=0
  • *2 Uses observedBits
  • *3 Hack with readContext
  • *4 Mutation trapped by Proxy triggers re-render

state value/object

Hm, I think context value in constate and unstated-next is state-based object as well. More precisely, output of useValue hook.

Updated.

Benchmark results for react-tracked, reactive-react-redux and react-redux.
the forked repo

screenshot1

screenshot2

screenshot3

Could you please add https://github.com/atlassian/react-sweet-state to the comparison

Added in the table. (Do you also mean to the benchmark?

To be honest I am more concerned about API and edge cases, as long as the majority of the work I am doing is faaaaaaaaar from real time.
Performance should not be terrible. Performance should not degrade due to application size or developer mistakes.

I wonder how you would evaluate edge cases...

All alternate state managers were created to solve some edge cases. You may try to figure out that problem (and in which way) some library was intended to solve, and try to apply the same case to another library)
Usually:

  • cascade update progression
  • componentization (aka isolation or microfrontends)
  • verbosity
  • working in a big team
  • working with a big state
  • frequent updates
  • derived data generation
  • stability and predictability
  • learning curve
  • more to come

There was a misunderstanding of mine about react-sweet-state. It uses readContext. Modified.
The README doesn't show the container. It can be used without Context.Provider because it reads the context default value.

Yeah, zustand by @drcmda is only a popular library with non-context solution. I've seen small/experimental libraries without context though.

I made a minimal example to compare actual bundle size with some libraries.

Build JS sizes
$ cd examples/counter-react-tracked
$ wc build/static/js/*.js
       1    2263  123254 build/static/js/2.0c3afdb5.chunk.js
       1      16    1066 build/static/js/main.088f65ad.chunk.js
       1      37    1502 build/static/js/runtime~main.a8a9905a.js
       3    2316  125822 total

$ cd examples/counter-constate
$ wc build/static/js/*.js
       1    2190  120150 build/static/js/2.f7ca4f1e.chunk.js
       1      16    1109 build/static/js/main.e21dfac9.chunk.js
       1      37    1502 build/static/js/runtime~main.a8a9905a.js
       3    2243  122761 total

$ cd examples/counter-zustand
$ wc build/static/js/*.js                  
       1    2222  121214 build/static/js/2.20ef84af.chunk.js
       1      18    1079 build/static/js/main.44ecf751.chunk.js
       1      37    1502 build/static/js/runtime~main.a8a9905a.js
       3    2277  123795 total

$ cd examples/counter-reactive-react-redux
$ wc build/static/js/*.js
       1    2976  148172 build/static/js/2.40846575.chunk.js
       1      18    1117 build/static/js/main.e8a6d259.chunk.js
       1      37    1502 build/static/js/runtime~main.a8a9905a.js
       3    3031  150791 total
JS file size (bytes) Diff (bytes)
react-tracked 125,822 +3,061
constate 122,761 0
zustand 123,795 +1,034
reactive-react-redux 150,791 +28,030

https://github.com/reusablejs/reusable by @adamkleingit and @morsdyce
This is a bit different from others. In terms of its implementation, the context value is multiple stores. Looks to me like multiple constate instances + selector interface. Interesting.

You may want to include https://github.com/avkonst/react-use-state-x to your research too. It is tiny, type-safe, feature-rich useState-like React hook to manage complex state (objects, arrays, nested data, forms) and global stores (alternative to Redux/Mobx). It's size is reported here: https://bundlephobia.com/result?p=react-use-state-x@0.9.1 but it includes Typescript headers too.

It would be interesting to see it's performance, but I have not done much about optimising the linear performance. I was more focused on reducing number and "size' of renders on state changes.

react-powerplug in hooks form 👍

@avkonst If I understand your implementation correctly, Observer triggers re-render for any state change. That’s just like the normal context. So, I might be misunderstanding something. It would be nice if there were a minimal example in codesandbox for global state.

Where can I find react-powerplug hooks form?

So not quite “tracked” ;)

For your table for react-use-state-x.
Context value in the internals of the implementation is a reference to the global state object. ValueLink returned by useStateLink, knows how to trace and unpack this reference in order to return the actual state value.
There are no dependencies.
Size is 2kb zipped, includes typescript headers, which should not be counted.
Optimisations for big object are:
Cache of nested value in the ValueLink instance.
Use multiple independent stores.
Use multiple observers deeper in the hierarchy instead of a top level single one context provider.
Use derived state link to manage local component statendependent on the global. See docs with the example about it.

The change to the value in the store is done via ValueLink set API, it is not js proxy and has got other functions, eg validate state according to user's validators.

So, If I were to add an item:

Context value Using subscriptions Optimization for big state object Dependencies Pakage size
react-use-state-x mutable reference to state object (which is basically equivalent to store) Yes (observer pattern?) Multiple stores No 2.9kB

Don't worry too much about the bundle size. Mine also includes unused functions. If you are interested, create a simple counter example and compare.

As far as I understand, your library focuses on mutating state in a good way.
My comparison table is about re-renders for state updates. (no mutation in mind)


That said, I'm getting to understand your lib. useStateObject and useStateArray are easier to get.
Whereas useStateLink looks a bit complex to me, maybe because I'm not very used to observer.
BTW, if you are OK with using Proxy, link.value can do the same job of link.nested, something like immer but different. (Ah, is it like MobX?)

Will add it to the comparison table.
The optimization column is about rendering state (not clearly stated though),
so your point should be in another column, but I don't create a new column at the moment.
(Maybe, I should limit the table for Redux-inspired/reducer-oriented libraries.)

I think I understand your point. As you may know my another library react-hooks-global-state has type-safe mutation but just for a (shallow) object, so it's like your useStateObject. And, you are basically doing it recursively for a nested object. That could be tough for typing.

Speaking of types, I found typing in immer works well. I didn't realize Proxy helps typing mutation operations.
Speaking of immer, have you looked into easy-peasy? It might be close to your mind set. (But, it's action based, so slightly different.)

I have an interesting idea, If you expose a custom hook for local state, it can work with react-tracked nicely. Wait a minute, you already have one useLocalStateLink. Let me try creating an example.

https://codesandbox.io/s/react-tracked-example-trb02
There you go. Enjoy.


If you understand what's going on, here's my suggestion: if you focus on creating a custom hook for local state that has handy setter (with typing), you can turn into global state with react-tracked, constate or anything you like. As I guess typed setters are the strength of your library, let the job for global state out.

Thanks. I will have a look. I do not understand now how usetracked hooks into ValueLink :)
Regarding optimisations, I tend to use multiple stores instead of multiple contexts. These are two different optimisations with different effect.

I understand your lib uses undocumented internal API. I am a bit concerned that it maybe gone in the future... What do you think?

I'd expect when they obsolete the unstable API, a new proper API will be provided.
If that's not the case, still I could implement without context and only with subscriptions (like zustand), but not sure it will be concurrent mode friendly.

multiple stores / multiple contexts

If I understand correctly, if one uses multiple stores, they will use context provider for each store (in this case a Observer is a provider). I guess we are talking about the same thing. There are rendering optimization and mutation optimization. The comparison table is only about the former.

You could put multiple stores in a context (see reusable), but it doesn't seem to be so in your lib.

OK. I see what you are doing in your library. Tracking of used within a component props matched with detected changes to force update for the affected subscribers. I think tracking has got the potential.

I have tried the approach with subscribers in the past (just subscribers, not the tracking) and got performance issues on the use case like this (it is artificial to demonstrate what happens when nested components rerender too):

const ComponentA = (props: { depth: number }) => {
    const state = useTrackedState();
    if (props.depth > 100) {
        return <></>;
    }
    return <><p>{props.depth}</p><ComponentA depth={props.depth + 1} /></>
}

I found that plain rerender all down to from the context provider works faster in this case.

I have tried observedBits feature of react to learn what it does. I see that observedBits do not make any effect, because the root level state change causes full tree rerender.

  • Could you please help me to understand why observedBits do not make any effect? (playground is here: https://codesandbox.io/s/react-tracked-example-j4o93 - I think it does the same as yours react-hooks-global-state except no name tracking, just hard coded bits).
  • How should I use context provider and useContext to trigger rerender of only components which use the context but not of the full tree?

observedBits / calculateChangedBits

You need to memo (or make children static) to stop normal render propagation (not context propagation) I've found it when I was experimenting another project of mine.

Fixed version: https://codesandbox.io/s/react-tracked-example-lxqw4

how to stop context propagation

It's a bit long, but read this.

Benchmark result with v0.7.0. dai-shi/reactive-react-redux#32 (comment)

2019-07-21 8 40 57

As the upstream is updated and react-redux-hooks is added,
my fork is updated accordingly.

Here's the latest result from the commit.

2019-08-08 21 51 32

2019-08-08 21 51 50

2019-08-08 21 52 12

Hi @dai-shi I have got some exciting news.

I have integrated some unique tracking to the react-use-state-x (it is renamed now to @hookstate/core) and I have got incredible performance results: see how Hookstate updates 1 out of 10000 table cells every millisecond: https://hookstate.netlify.com/performance-demo-large-table !

I have done few things differently:

  1. It tracks not only what is used (proxy tracking), but it also tracks where it is used (most near hook subscription) and what subset of the data is set (as you noticed before nested data mutation is the strong advantage of the Hookstate, and it leverages it now for performance).
  2. It does NOT use deep equality (in fact it does NOT use any equality at all) to locate the affected components. This makes very big improvement on performance results according to my early experiments.
  3. It works with global and local (per component) states in the exact same way, i.e. it can optimize rerendering of the state created per mounted component: see this, this and this
  4. It has got plugable extensions, so the core library is even smaller now than it was before. There are about 8 standard plugins available. For example, to enable access to initial value, modification status, validation, persistence, extended mutation methods for arrays, etc. The plugins are not documented and not demoed yet, but I am working on it.

So, you can update the table now as:

  • Context value - context provider is not used at all. A client may still use it to pass the store reference around, but it does not give any benefit
  • Using subscriptions - yes. It uses store subscriptions and it also uses nested 'scoped state link' subscriptions for more efficient indexing.
  • Optimization - described above, main is proxy based tracking
  • Dependencies - no
  • Package size - 2.13KB

Please, let me know what you think. I am interested in to see how it stands against others in your performance tables.
Thanks for the inspiration and your performance research.

-- Andrew

@hookstate/core size is 2.13KB (tested on plain Create React App + @hookstate/core)

@avkonst
https://github.com/avkonst/hookstate/blob/master/src/UseStateLink.tsx
Just read through the core part of your lib. If I understand it correctly, it's quite similar to my original motivation of developing reactive-react-redux after I developed react-hooks-global-state.
Like we know which part of the state is changed, and we update component that uses that part.
I didn't think about the deep comparison back then when I started.

Now, it seems like your new benchmark target is MobX. They use Proxy to watch mutation and update component based on the mutation. So, that part is different from your lib. Still, it's similar in a sense both connect mutations and component updates. Whereas my libs are unopinionated about mutations. But, it has to be immutable updates, so it's opinionated in that sense.

One thing I noticed, because your state has special setters anyway, it could have special getters.
So, like state.nested.counter.set(p => p + 1), you could get a state value by state.nested.counter.get() instead of state.value.counter. It looks more consistent.
And, the benefit is that you don't need to use Proxy in this pattern.
I know, for some people, the use of Proxy is a blocker to introduce reactive-react-redux.

I will update the comparison table in this issue, but the package size will be of bundlephobia. I know it's not accurate, but it's at least fair to compare with others (and it's not my fault). I don't want to measure sizes of all libraries with CRA.

The benchmark tool is js-framework-benchmark, and my fork is this.

plus info not need it myself at the moment.

Well, in that case, I know you wouldn't be motivated.
Without Proxy, the performance can technically be better, though.
Anyway, I was not suggesting it for performance. I just thought the symmetric pattern of
state.nested.counter.set(...)
state.nested.counter.get()
might look good.

a link to the source where you will put hookstate for comparison?

Do you mean if I would add it to my fork?

Where is the implementation of benchmark tests for mobx lite? There is only for mobx in your fork

Yes, it's only for mobx and I didn't create one. It's from upstream.

What I did with mobx-react-lite is this: https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode

@dai-shi could you please explain what 'State object has to be immutable' column means? If it means that a client should not (can not) modify state value directly, but needs to you an API to set the state, you should put yes for the hookstate. In fact if you attempt to call 'state.value.nested.counter = 3', you should receive crash, as the hookstate does not allow to set the value directly and requires to use 'state.value.nested.counter.set(..)'

@avkonst
About the benchmark, would you write a test in your fork?

State object has to be immutable

This is a new column to distinguish Redux-based approach and others. In Redux, you would never update a state object (It can be freezed) and if one did, it wouldn't behave correctly. So, it's basically a limitation, because JavaScript objects are mutable by nature.

MobX doesn't have the limitation thanks to Proxy. It can trap mutations. Your lib doesn't use Proxy for mutations, but set() does the similar job. I thought your lib doesn't have the limitation like MobX at least internally. Now,,,

'state.value.nested.counter = 3', you should receive crash

I see, yeah. So, it's like it doesn't allow direct mutations, but only calling mutating functions.
Maybe, I should put "N/A" in that cell because it's confusing? Or, do I misunderstand that the internally objects are mutable?

"In Redux, you would never update a state object"

how would you set the state in this case, if you can not update it?

" Your lib doesn't use Proxy for mutations"

This effect ''state.value.nested.counter = 3', you should receive crash' is achieved by proxy tracking. The proxy is used to block the mutations in order to force developers to use explicit StateLink.set(...) API. In theory the proxy could map the mutation to StateLink.set() like it does for value/get(), but maybe in the future.

"So, it's like it doesn't allow direct mutations, but only calling mutating functions."

correct

Maybe, I should put "N/A" in that cell because it's confusing?

no just leave it as it is. The Hookstate's state value is mutable and traced, like you say in Mobx.
I think you should explain what this column means.
also if you see the 'immutability' is a limitation, I suggest you invert the column to 'Is state value mutable'.

how would you set the state in this case, if you can not update it?

Create a new state object like newState = { ...oldState, count: oldState.count + 1 }

In theory the proxy could map the mutation to StateLink.set() like it does for value/get(), but maybe in the future.

That would be nice, and I understand why you don't take get() approach if you see this future.
But then, it will loose typing of set() which I think is a unique selling point of your lib.

should explain what this column means

Maybe I should remove the column entirely. Mutable state is from the MobX perspective, and I think in React and/or Redux, in general, immutability is the basis (and purity too). I'm surely biased, but we would never reach the agreement in that sense. (The intention was to show that your lib is close to MobX in the comparison table.)

The intention was to show that your lib is close to MobX in the comparison table.

I see

But then, it will loose typing of set() which I think is a unique selling point of your lib.
It will not loose typing:

state.nested.counter.set(...here it will expect number...)
state.value.counter is of type number, and if it is set, it can be set only to number.
But it will have other disadvantages. For example:

  • state.value.othercounter is UNDEFINED (although typescript will not allow to write this code, but ignore it for a moment). state.nested.othercounter is DEFINED,
  • state.nested.othercounter.value would be undefined too because of the above original source
  • but it is still possible to call like the following:
    • 'state.nested.othercounter.extended.valid' (valid is coming from the @hookstate/validation plugin, and can be defined to custom validator, which may check, for example, that the value is actually defined :) and return true for valid in this case, or false otherwise). So moving to purely proxied values would not allow to inject plugins and extend the functionality of the state.
    • (if there was no 'nested' state link, but pure value, it would require here to pass 2 properties: the value and the callback on change)

It will not loose typing.

Oh, yes. You are totally right. That's my bad.

But it will have other disadvantages.

Would you look into MobX and see how they solve the issues, or not?

I personally didn't write any benchmark code for MobX.
I tried mobx-react-lite and knew it would only trigger render for the parts changed.

But, the point I'm raising is not about the render part, but the mutation part.

What do you mean? What would you like me to look at?

I didn't yet fully understand all "disadvantages" you mentioned.
Wondered if it's solved in MobX or not.
If it's solved, you learn from it. If not, that's the point your lib beats MobX, right?
(And, if you already have an answer, no need to look at.)

"... all "disadvantages" you mentioned. Wondered if it's solved in MobX or not."
It is not solved in MobX, they just do not have some of the features of the Hookstate, it is not their scope, eg. valuelink pattern for state 'leaves', various features like validation, etc.. For example, I have no idea how to put Performance metrics counter with Mobx similar to the one I put for Large Table performance demo. This is maybe where Hookstate beats Mobx.
However, I guess the main point where Hookstate beats Mobx is the following. As far as I understand, Mobx optimizes the rendering only when it is used like this by multiple consuming components. That is all clear, implementation is based on components putting global state subscribers. As far as I understand Mobx does not provide the analogy of this one or this one. It will rerender the whole upper lever state consuming component. Maybe I am missing something and Mobx can do this too, but I have not found how to achieve the same with Mobx. So, I asked if you have got some benchmarks.

Thanks for the explanation.
Unfortunately again, I don't have any benchmarks.

They tell here https://mobx-react.js.org/observer-hook:
Despite using useObserver hook in the middle of the component, it will re-render a whole component on change of the observable. If you want to micro-manage renders, feel free to use or useForceUpdate option (for advanced users).
But I can not understand how a client can achieve it and how not to mess up with optimization, which could result in missed rerendering.

The reason I thought the benchmark target of your lib is MobX, is about mutations not renders.
But, it seems that it's not the case, so you might not need to compare with MobX.

I have thought about putting the same large table performance demo using Mobx to compare, but I have not figured out how to achieve it with Mobx :) It seems like each table cell should be a consumer of the global state to make sure only the affected cells are rerendered, or there is another way?

I'm willing to help to compare with react-tracked. 😄
Honestly, I'm not very familiar with MobX and I did have a misconception before, so I can't be of much help.

"I'm willing to help to compare with react-tracked. 😄"
Yes, I will be glad to see the results! And the benchmark code base.

Hi @dai-shi Hookstate now supports IE11 via @hookstate/proxy-polyfill plugin. Example project is here. Could you please let people know, who were interested in ES5 compatible state management system with usage tracking support. Thanks.

I don’t read the code as I’m on vacation now, but do you deal with non-get proxy handlers?
I don’t know anybody who would be interested in your lib with proxy-polyfill. Sorry about that.

Interesting. So, you don’t use/need google lab’s proxy-polyfill.

There was a general discussion whether proxy can be polyfilled for IE11 in the Redux community.

I don’t have the link right now, but it’s the long discussion about hooks.

By the way, if you can implement your lib without proxies that easy, you wouldn’t need proxies from the start, would you? That might be simpler.

Let’s continue the discussion in avkonst/hookstate#6

I will close this issue later as it becomes very long mostly discussing about hookstate.

Copied the table in README.

@dai-shi can you add hookleton and garfio to the comparison table?

author here.

@bySabi Thanks for coming here.
Unfortunately, if I were to add your libs, there would be so many other libs I would need to consider.

If there's a huge demand on a comparison table, I should probably start a new repo. Hm, that would not be a bad idea. I could add more columns to compare. Adding benchmarks would also be nice.


BTW, your implementation of

const forceUpdate = s => ~s;

might have an issue with batched updates.

@dai-shi thanks for take the time for look at the code. Please ping me when the new comparison repo is done.

I also thought that s => ~ s might have problems with the batch update but in practice I haven't had any.
I have not looked at the React code to know how the algorithm works in detail.
In my case the state changes using the sequence "infinite" -1 0 -1 0 -1 ... I don't think React risks ignoring such a simple state change.
The other solutions out there like: !BOOLEAN and even increasing a counter could have the same problem of ignoring changes of state.
What solution do you propose?

I used to use s => !s but there was an issue reported. If you update twice in a single batch, React "bails out" rendering and users won't see the update. This issue is easily reproducible. Just update 2x times in a batched callback.

Now, I use s => s + 1 in my libs. This is not infinite as you may imply.
More precisely, it should be s => s === Number.MAX_SAFE_INTEGER ? Number.MIN_SAFE_INTEGER : s + 1, maybe?

I imagine you don't have the link to the issue at hand :-)? ... I would like to try an issue repo

s => s === Number.MAX_SAFE_INTEGER? Number.MIN_SAFE_INTEGER: s + 1
It would be the most purist but the least performant :-)

Now that I am looking at how to add Typescript support, I will probably update forceUpdate

I imagine you don't have the link to the issue at hand :-)?

Let me see...

dai-shi/reactive-react-redux#20 (comment)
dai-shi/reactive-react-redux@c9132a9

Here you go.

Thanks for the link, they have been useful to understand the problem.

Well, it seems that in my case the 'forceUpdater' s => ~ s is not a problem because it is called from a useMemo that "observes" the changes in the user's provided hook output so React is in charge of tracking, and Batching is desirable. https://github.com/bySabi/hookleton/blob/master/src/index.js#L51

Using s => s + 1 does not change anything, in my case. Just as in this example:

import React from 'react';

const App = () => {
  const [s, forceUpdate] = React.useReducer(s => ~s);
  //const [s, forceUpdate] = React.useReducer(s => s + 1, 0);

  console.log('render: ', s); // should be out: "render 0"

  React.useMemo(() => {
     forceUpdate(); // should be out: "render 1"  -- NOT RENDERED --
  }, []);
  React.useMemo(() => {
     forceUpdate(); //should be out: "render 2"
  }, []);

  return null;
}

export default App;

When I create hookleton I try to make it completely user's hook output transparent and let the React "hook" engine do all the work.

Although surely there is something that escapes me. The code needs other hawk eyes :-)

Thanks for your time.

@dai-shi It's a really interesting benchmark. I'm a React developer but I'm a beginner in terms of measuring performance. How did you make them ? How do you know which features to compute and how did you calculate them ? I want to know how to make a performance evaluation of a library or tools like that.

Benchmark results for react-tracked, reactive-react-redux and react-redux. the forked repo

screenshot1 screenshot2 screenshot3