reduxjs / react-redux

Official React bindings for Redux
https://react-redux.js.org
MIT License
23.4k stars 3.36k forks source link

Discussion: Potential hooks API design #1179

Closed markerikson closed 5 years ago

markerikson commented 5 years ago

Let's use this thread to discuss actual design considerations for an actual hooks API.

Prior references:

Along those lines, I've collated a spreadsheet listing ~30 different unofficial useRedux-type hooks libraries.

update

I've posted a summary of my current thoughts and a possible path forward.

MrLoh commented 5 years ago

I think a dependencies array is the obvious way to deal with props, it’s a well known pattern in hooks land and minimal effort, especially with the lint rule. Redux is all about being more explicit over being most succinct anyway.

Needing to wrap components to use hooks is obviously extremely unintuitive. Do none of the other libs have a better solution to this issue? Since it only is needed to handle mixing and matching connect and hooks, there should be a way to opt out for projects that will not mix and match, then it sounds like a workable intermediate workaround.

esamattis commented 5 years ago

Did some reading on the v7 code too and came to pretty much to the same conclusion as @ricokahler: It doesn't seem like it's possible to implement hooks without some kind of wrapping component unless React itself would come forward and give us something like

My hooks implementation is indeed way too naive.

ricokahler commented 5 years ago

My hooks implementation is indeed way too naive.

@epeli If it makes you feel better, I have an even more naive useRedux implementation in prod right now lol


I agree with everyone that the hooks still requiring the HOC is annoying/gross but I think in practice, it won't change how people use the redux hooks. See this example pasted from my previous post:

// this custom hook isn't aware of `enableReduxHooks` and everything is fine
function useTodo(todoId) {
  const todo = useRedux(state => state.todos[todoId], [todoId]);
  return todo;
}

const Todo({ id }) {
  const todo = useTodo(id);
  return <div>{/* ... */}</div>
}

export default enableReduxHooks(Todo);

I would also like to figure out a way to do it without a wrapper but I don't think it's possible.

For those who haven't read the code: the only reason we need the wrapper is to add that subscription layer to enable top-down updates. See here from the v7 branch: https://github.com/reduxjs/react-redux/blob/79982c912d03c55ac7059b61806a1387352e91f3/src/components/connectAdvanced.js#L387-L394

This Provider propagates the Subscription instances to the next component wrapped in connect:

https://github.com/reduxjs/react-redux/blob/79982c912d03c55ac7059b61806a1387352e91f3/src/components/connectAdvanced.js#L204-L209

By the nature of HOCs, it's easy to wrap the component with another provider seamlessly but with hooks, there is no way to do that 😭


After I figured that out, I admitted defeat and started seriously thinking about how to implement useRedux (aka useMapState, useSelector) with the constraint of the wrapper and it's actually kind of nice from an implementation standpoint.

Earlier in the thread, @Jessidhia brought up the same API as useRedux just named useSelector and she said:

[The useSelector API] would also have the side-effect of creating a separate subscription per useSelector, though. I don't know if that's a relevant performance consideration or not.

With the wrapper, instead of having one redux subscription per useRedux (aka useSelector) call, it would be one redux subscription per enableReduxHooks call.

Internally the enableReduxHooks wrapper could create one subscription and manage pushes to each useRedux call inside that "subscription layer" (where each enableReduxHooks call is one layer).

I think this would bring performance on par to how connect works now because this would make it work like how connect works.


It's unfortunate and gross that the HOC is still needed but with this constraint there is a clear path to fully function redux hooks that work alongside with connect πŸŽ‰ (in my mind at least)

Maybe I'm not thinking outside of the box enough but if we can figure out how to get the tiered subscriptions (e.g., the "order matters") without the wrapper then we can ditch the HOC.

MrWolfZ commented 5 years ago

@ricokahler your posts reflect my own thoughts almost exactly. I also got to the conclusion that currently the HOC will still be required. Instead of enableReduxHooks my idea was to just call it connectHooks to stay in line with existing naming.

I think this code (basically your example with some inlining) is quite concise and readable.

export const Todo = connectHooks(({ id }) => {
  const todo = useRedux(state => state.todos[id], [id]);
  return <div>{/* ... */}</div>
})

Regarding the alternative approach of "enforcing" that no props are used for selecting the state (which would only be possible with linters as far as I can see), I think it would definitely be worthwhile to do some performance tests to see how big the impact of those additional renders really is. I have some free time on my hands the next couple of weeks, so I may try my hand at it.

Lastly, I also plan to take a deep dive into the fiber implementation. Maybe I'll find some hidden nugget that would allow us to have a proper hooks-only implementation.

saboya commented 5 years ago

Giving up the possibility of filtering state with props is not an option in my opinion. Maybe for some, but for some cases it's going to cause a huge amount of unnecessary renders.

I think the hybrid approach (HOC + hooks) is the way to go for now. The main issue with the HOC isn't the HOC itself IMO, but the code clutter and ergonomics. And, if in the future there's a HOC-less solution, the migration path will be almost pain-free, and the HOC can be updated to a NOOP.

markerikson commented 5 years ago

I really don't like the idea of having to use a HOC just to use hooks.

Unfortunately I do agree that it looks like it may be a necessity atm, but I would really like to try to come up with another option.

The vague notion I'm tossing around in my head is if there's some way we can construct our own in-memory tree structure in <Provider> that mirrors the shape of the store subscriptions in the React tree, so that we can trigger updates in the appropriate sequence. Problem is, we don't actually know where each component is in the tree.

If y'all have any ideas on how to pull off such a feat, I'm all ears.

MrWolfZ commented 5 years ago

@saboya One of the suggestions above was something like the following code, which would put some burden on the library users to always remember to use useMemo, but should keep performance problems minimal due to not everything being re-rendered (again, only tests with concrete numbers will verify or contradict this).

export function Todo({ id }) {
  const todos = useRedux(state => state.todos);
  const todo = todos[id]
  return useMemo(() => (
    <div>{/* ... */}</div>
  ), [todo])
})
markerikson commented 5 years ago

@MrWolfZ : the question of how many components render is only part of the issue.

In that snippet, yes, the child components memoized by the useMemo() would only re-render when todo changes, because React will bail out when it sees the === elements in this spot as last time.

However, the Todo component itself will still have to render even if todo hasn't changed, and it's the aspect of React getting involved at all that's ultimately a perf issue.

Now, we ought to try building these options out, and then updating the benchmarks suite to have some hook scenarios for comparison with our existing HOC scenarios. (No idea how that will work out).

otakustay commented 5 years ago

I don't think using dependencies to useRedux is a good idea, suppose we use redux like:

const User = ({id}) => {
    const user = useRedux(
        state => state.users[id],
        [id]
    );

    return <div>{user.name}</div>;
};

It looks very concise and readable by specifying id as a dependency to mapState function, however this implies a fact that the selector won't change around renders, which is not a stable consumption:

const User = ({userType, id}) => {
    const selectUser = userType === 'admin'
        ? state => state.admins[id]
        : state => state.users[id];
    const user = useRedux(selectUser, [id]);

    return <div>{user.name}</div>;
};

User won't get correct result from redux if userType is changed without id change, and lint rules like exhaustive-deps is not able to detect the fact that selectUser actually depends on userType variable.

We have to tell developers to modify this example code to:

const User = ({userType, id}) => {
    const selectUser = state => userType === 'admin' ? state.admins[id] : state.users[id];
    const user = useRedux(selectUser, [userType, id]);

    return <div>{user.name}</div>;
};

This may be OK in a self controlled app but is not the case as a general purpose library like react-redux, we should try to satisfy all kinds of developers without having implicit restrictions.

I'd prefer to manage my selector via useCallback myself:

const User = ({userType, id}) => {
    const selectUser = useCallback(
        userType === 'admin'
            ? state => state.admins[id]
            : state => state.users[id],
        [userType, id]
    );
    const user = useRedux(selectUser);

    return <div>{user.name}</div>;
};

This is more straightforward and is able to utilize exhaustive-deps lint rules

ricokahler commented 5 years ago

Unfortunately I do agree that it looks like it may be a necessity atm, but I would really like to try to come up with another option.

@markerikson a babel plugin? idk

last time I checked. create-react-app didn't play nicely with those though


Edit: there are these things called babel macros that work with create-react-app. I'm not sure what they do exactly but maybe we can babel some HOCs in magically.

Edit edit: In order to use babel macros, the importing line has to match /[./]macro(\.js)?$/.

Is that cool or gross? import useRedux from 'react-redux/macro' is gross.

Eh that's about as far as I'm willing to go for considering babeling enableReduxHooks.

MrLoh commented 5 years ago

@otakustay this is a bad example. Just like with any hook, functions also need to be specified as dependencies, so if you pass selectUser as a reference, then you need to specify it as a dependency and the exhaustive-deps rule would also capture that missing dependency on the function in useMemo or useCallback. In your example id is actually not a dependency of that hook but just the function is. See https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies and also https://overreacted.io/a-complete-guide-to-useeffect/

otakustay commented 5 years ago

@MrLoh

Just like with any hook, functions also need to be specified as dependencies

Except useCallback. What I'm discussing is actually "should useRedux includes the capability of useCallback", as a result I said NO, we should just keep selector function as a dependency of useRedux

t83714 commented 5 years ago

@MrWolfZ

Maybe you already took this into account, when writing your suggestion. If so, I apologize.

Thanks for the sample code. I noticed React-Redux's Subscription implementation but I think we can avoid that by connecting redux via state πŸ˜„

My statement all props are up was talking about one component.

I think why the zombie child component is unintuitive & really need a fix is because that's the situation where data of the SAME component are consistent.

i.e. the stale props (props.id) and state (todos[props.id]) of the same component could be not synced (at a point in time)

The case of your sample code is two components' internal state could be not synced at the same time.

I think we probably shouldn't need to worry about this case too much as you probably hardly see it in a real-life example? --- If you subscribe to the same state, there would be no point of passing the state via props. And if you don't pass the state data to the child component via props, it will be no chance that two copies of states exist in the SAME component.

MrWolfZ commented 5 years ago

@markerikson Regarding this comment

@MrWolfZ : the question of how many components render is only part of the issue.

In that snippet, yes, the child components memoized by the useMemo() would only re-render when todo changes, because React will bail out when it sees the === elements in this spot as last time.

However, the Todo component itself will still have to render even if todo hasn't changed, and it's the aspect of React getting involved at all that's ultimately a perf issue.

Now, we ought to try building these options out, and then updating the benchmarks suite to have some hook scenarios for comparison with our existing HOC scenarios. (No idea how that will work out).

I found an interesting statement in the official react dooks regarding useReducer having a similar behaviour. Let me quote it here:

If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go β€œdeeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.

So they seem to think that having that single component re-render without any children also re-rendering shouldn't be too much of an issue.

That said, I completely agree that both API designs I posted are ugly and should be prevented if possible.

MrWolfZ commented 5 years ago

@t83714 if you look at the example @markerikson gives for the zombie child problem, you will find a pattern that is likely to occur in real applications (otherwise, how would they have noticed the zombie child problem in the first place?).

MrWolfZ commented 5 years ago

Sorry for the spam, but just for completeness' sake, here my proposed hooks implementation that requires neither a HOC nor use of useMemo and does not suffer from zombie children, but has the downside of calling the state selector with inconsistent props and also calling it on each render and twice for each relevant store change. You can see it in action here with the zombie child example from @markerikson. Of course it is not fully performance optimized yet and I am sure I am missing some edge cases, but this should demonstrate the idea of just ignoring errors during the subscription callback.

import { ReactReduxContext } from "react-redux";
import { useContext, useReducer, useRef, useEffect } from "react";

export function useRedux(selector, deps) {
  const { store } = useContext(ReactReduxContext);
  const [_, forceRender] = useReducer(s => s + 1, 0);

  const latestSelector = useRef(selector);
  const selectedState = selector(store.getState());
  const latestSelectedState = useRef(selectedState);

  useEffect(() => {
    latestSelector.current = selector;
  }, deps);

  useEffect(
    () => {
      function checkForUpdates() {
        const storeState = store.getState();
        try {
          const newSelectedState = latestSelector.current(storeState);

          if (newSelectedState === latestSelectedState.current) {
            return;
          }

          latestSelectedState.current = newSelectedState;
        } catch {
          // we ignore all errors here, since when the component
          // is re-rendered, the selector is called again, and
          // will throw again, if neither props nor store state
          // changed
        }

        forceRender();
      }

      checkForUpdates();

      return store.subscribe(checkForUpdates);
    },
    [store]
  );

  return selectedState;
}
t83714 commented 5 years ago

@MrWolfZ Thanks for the example link. I used a much simple array based sample app to reproduce the problem πŸ˜„ I think you're right. As long as we set up separate subscriptions for components, it's unavoidable unless we use batchUpdates or custom subscription logic to enforce top-down updates πŸ‘

ivansky commented 5 years ago
  1. Do not use whole Store context changes to update components. It's not a good performance. Latest context API is pretty slow for searching child subscribed components.
  2. We do not need the whole store state for each component. Let's use particular selectors to select what appropriate component needs.
  3. Do not update components if the reference to selector is changed. A function might be recreated by accident but there is not a case to change input selector.
  4. If we need any props as dependencies then the better approach is using an array of deps because comparing objects looks slower. useStoreSelector((state, [a, b]) => selectSome(state, a + b), [a, b]).
  5. Using HOC to reduce subscribe count is pretty unnatural. Let's use linter rules for it. A developer has to keep in mind some simple things.
ricokahler commented 5 years ago

How critical is it to be able to use component props as part of your state extraction logic?

Here are my two cents on this ^ discussion point:

@markerikson said

However, the Todo component itself will still have to render even if todo hasn't changed, and it's the aspect of React getting involved at all that's ultimately a perf issue.

I agree with this. If you have 100 or so connected components in a list and all 100 of those update, that's still 100 updates regardless if those updates are fast or not. I acknowledge that you can useMemo to speed up all 100 of those updates… but it's still 100 updates.

For example, if you have 100 <Todo />s in a list and you allow the user to edit the title of the todo using an <input />, then you will feel the 100 updates with every keystroke. I've been in that situation before and I've learned my lesson. From my experience regarding components, it's never a good idea to turn an O(1) problem into an O(n) problem if you don't have to.


However, @gaearon in this facebook incubator issue has also gave the notion of an API that doesn't take in props.

Here is a full quote of his post:

To be clear I'm not suggesting this particular API. Just that the notion of running a selector that involves props to determine a bailout has a lot of pitfalls (such as handling stale props), and can cause other issues in concurrent mode (such as the current code which mutates refs during rendering). It doesn't really map to the React conceptual model nicely. From React's point of view, props don't really exist until you render β€” but this Hook is trying to "bail out" at arbitrary times outside rendering.

One alternative design could be to allow selectors but only static ones:

import {createSelector} from 'react-redux-hook'

const useTodosLength = createSelector(state => state.allTodos.length)

function Todos() {
  const todosLength = useTodosLength()
  return <h1>Total: {todosLength}</h1>
}

If you need to select based on a prop value, you can do this with a component layer:


import {createSelector} from 'react-redux-hook'

const useTodos = createSelector(state => state.allTodos)

function Todos() {
  const todos = useTodos()
  return todos.map(todo => <Todo {...todo} />)
}

const Todo = React.memo(() => {
  // ...
})

Essentially, in this case the selecting component is your mapStateToProps. :-)

His post echos what @t83714 had said previously just lifting the selector out of the actually hook.

From an API standpoint, the static creatorSelector makes sense if you don't want to include props but in general, I still think using props to select a specific piece of state is necessary to make things scale.

BUT theory is theory. Until we build it out and benchmark it, there's no way to know. Maybe something like createSelector is the way to go? I'm skeptical but always open to different ideas (e.g. I'm still thinking about babeling that HOC in).

MrWolfZ commented 5 years ago

I finally have some numbers for y'all. I implemented two alternatives for useRedux which you can see here. Alternative 1 is my proposed implementation from above with some slight adjustments. Alternative 2 is the approach of a static selector and useMemo.

To benchmark this I have created a copy of the deeptree benchmark for each alternative and made some minor adjustments for each alternative. For alternative 1 I replaced ConnectedCounter with this:

const Counter = ({ idx }) => {
  const value = useReduxAlternative1(s => s.counters[idx], [idx])
  return <div>Value: {value}</div>
};

For alternative 2 I replaced ConnectedCounter with this:

const selector = s => s.counters;
const Counter = ({ idx }) => {
  const counters = useReduxAlternative2(selector)
  const value = counters[idx]
  return useMemo(() => <div>Value: {value}</div>, [value])
};

Below you can find the results. Assuming I have correctly implemented everything, alternative 1 is on par with 7.0.0-beta.0. However, alternative 2 is massively slower (much slower than I expected).

@markerikson Which of the benchmarks do you think would be best suited to test the hooks implementations? I don't really want to re-implement all of them for all alternatives, so it would be great to know which your preferred one is (e.g. because it reflects real world usage best).

Results for benchmark deeptree:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Version      β”‚ Avg FPS β”‚ Render       β”‚ Scripting β”‚ Rendering β”‚ Painting β”‚ FPS Values                                      β”‚
β”‚              β”‚         β”‚ (Mount, Avg) β”‚           β”‚           β”‚          β”‚                                                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 5.1.1        β”‚ 14.55   β”‚ 109.9, 0.1   β”‚ 6466.00   β”‚ 8835.47   β”‚ 3001.32  β”‚ 14,15,14,15,14,15,14,15,14,15,14,15,14,15,14,14 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 7.0.0-beta.0 β”‚ 24.11   β”‚ 111.8, 0.8   β”‚ 337.77    β”‚ 13320.03  β”‚ 4784.63  β”‚ 24,25,24,23,24,25,24,24                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Results for benchmark deeptree-useReduxAlternative1:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Version     β”‚ Avg FPS β”‚ Render       β”‚ Scripting β”‚ Rendering β”‚ Painting β”‚ FPS Values                                   β”‚
β”‚             β”‚         β”‚ (Mount, Avg) β”‚           β”‚           β”‚          β”‚                                              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 7.0.0-hooks β”‚ 24.05   β”‚ 111.7, 0.7   β”‚ 536.28    β”‚ 13591.96  β”‚ 4872.18  β”‚ 23,22,23,24,23,24,23,22,24,25,24,23,24,25,25 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Results for benchmark deeptree-useReduxAlternative2:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Version     β”‚ Avg FPS β”‚ Render       β”‚ Scripting β”‚ Rendering β”‚ Painting β”‚ FPS Values β”‚
β”‚             β”‚         β”‚ (Mount, Avg) β”‚           β”‚           β”‚          β”‚            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 7.0.0-hooks β”‚ 7.96    β”‚ 104.6, 4.6   β”‚ 15053.94  β”‚ 4996.25   β”‚ 1724.94  β”‚ 8,7,8,8    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
ctrlplusb commented 5 years ago

Update: got some great review feedback from @MrWolfZ - I'm definitely on the drawing board still. But really happy to have this issue known to me now. Shall feedback with any updates as and when.


Hi all, joining this discussion as @MrWolfZ informed me that my library would suffer the same "zombie child" issue as discussed here.

I've managed to get around this by lifting the redux store subscriptions for the hooks to the root context, which allows me to execute the mapStates in an order that is more likely to represent the component hierarchy.

PR is here: https://github.com/ctrlplusb/easy-peasy/pull/137

Demo: https://codesandbox.io/s/pm39mzn13m

I still need to do a lot more tinkering. But wanted to put this on your radar. Hoping to get some early feedback on whether this is a crazy idea or not. πŸ‘

ricokahler commented 5 years ago

Here is a very preliminary proof of concept branch with the HOC + hooks and here is a forked demo of the zombie child with the connect hooks

Don't take the code too seriously, I didn't spend much time on it (or test it). The only important thing here is the idea. I think we all knew that something like this would work but here is something a little more concrete.

Here's what it's doing (or at least trying to do):

  1. each wrap with connectHooks, creates a "subscription layer" where each call to useRedux will subscribe to a "hooksManager" and then the hooksManager will propagate to each useRedux hook in the "layer"
  2. All the updates to each useRedux subscriber in the layer is batched
  3. After the batched updates, the current layer will notify the next layers underneath it.
esamattis commented 5 years ago

Checkout react-hooks-easy-redux by @dai-shi. It seems to me it dodges these issues entirely. The api in it is simply

const state = useReduxState();

No map state etc. It uses proxies to monitor what parts of the state is used in a given component and just forces rerender for the component if a monitored part of the state changes. So any state mapping you need to do you can do directly in the render. So in this case batchedUpdates should be enough to prevent issues with stale props?

At least it passes this test https://codesandbox.io/s/2p4kxnxj1j

Here's some more discussions at https://github.com/dai-shi/react-hooks-easy-redux/issues/10

Not 100% sure how performant this solution is.

Also obvious downside of it is the requirement for Proxies so IE11 support can be a challenge.

ricokahler commented 5 years ago

An even simpler API and it doesn't suffer from inconsistent props? What a gem.

Also obvious downside of it is the requirement for Proxies so IE11 support can be a challenge.

Unfortunately, I have to support IE 11 :( . Since we have the properties we want to proxy from the store, we can iterate through them and then use Object.defineProperty for the proxy mechanism instead of ES6 proxies.

esamattis commented 5 years ago

Yeah, I think immer does it like that if there's no Proxy support https://github.com/mweststrate/immer#immer-on-older-javascript-environments

esamattis commented 5 years ago

If I understand correctly another downside in react-hooks-easy-redux is that you cannot bail rendering if you only use derived state to produce output.

Ex.

function HasComments(props) {
    const state = useReduxState();
    const hasComments = state.posts[props.postId].comments.length > 0;
    return hasComments ? "yes" : "no";
}

This component would always render when comment count changes. Not just when it goes from 0 to something else.

faceyspacey commented 5 years ago

@epeli not with the fork of redux i'm working on where selectors are created at store creation time: createStore(reducer, selectors, etc).

See:

jeremy-deutsch commented 5 years ago

@epeli I should note that react-hooks-easy-redux also has issues with Concurrent Mode, since it uses side effects in render (by attaching side effects to property accesses).

dai-shi commented 5 years ago

react-hooks-easy-redux also has issues with Concurrent Mode, since it uses side effects in render

It should work in Concurrnt Mode. It only updates refs in commit phase.

MrWolfZ commented 5 years ago

@dai-shi I really like the elegance of the proxy approach. Here are my two cents on the pros and cons:

pros:

cons:

One way to solve the intermediary value accesses without needing a redux fork or similar is to still use an API which passes the selector to the hook, but then uses proxies to trace property accesses inside the selector to determine equality. This would alleviate the stale props issue since something like items[id].prop will not throw an error if the item with id does not exist anymore.

Final note regarding concurrent mode: As @markerikson noted in a source code comment, I believe as well that any solution which accesses store.getState() during render will have issues with concurrent mode, which includes your library.

dai-shi commented 5 years ago

@MrWolfZ Thanks for your attention to the library and your detailed pros/cons! I will refer your comment in my issues for further discussion.

faceyspacey commented 5 years ago

@MrWolfZ

it makes render impure since through the proxy

That's not so much unlike the react-redux hoc which is impure as a parent component doing the work. There's gonna be impurity somewhere until React builds a global state store into React itself (and passes it down to all as a 2nd argument to all components), and it's very clear they have gone the opposite direction of Redux (probably because its lack of modularity).

One way to solve the intermediary value accesses without needing a redux fork or similar is to still use an API which passes the selector to the hook, but then uses proxies to trace property accesses inside the selector to determine equality

@epeli was saying that the closure is the real problem. So if he's correct and if the selectors weren't defined in the component function, or at least didn't enclose any variables, and rather the variables were passed separately, I think you're right, and selectors could work without a fork! Eg:

const hasComments = (state, postId) => {
   return state.posts[postId].comments.length > 0
}

const MyComponent = props => {
    const deps = [props.postId]
    const state = useReduxState(hasComments, deps)
    return <div>{hasComments ? 'yes' : 'no'</div>
}

That should work, as there is no difference between passing hasComments there or on store creation.

lot of good brainstorming happening around @dai-shi's library! (and not to mention @theKashey's lib: proxyequal which is the basis for all this!!

dai-shi commented 5 years ago

Lately, I'm designing an experimental selector based hook in my project https://github.com/dai-shi/reactive-react-redux (the project name changed.) I ended up with developing something similar to what @MrWolfZ proposed in https://github.com/reduxjs/react-redux/issues/1179#issuecomment-476105825 with Proxy.

If Proxy-based approach is still immature for react-redux v7, and I suppose so, @MrWolfZ 's hook would be good for the official react-redux hook as it keeps the same mental model. Just my two cents.

markerikson commented 5 years ago

Yeah, unfortunately, I don't think I can justify using anything that relies on Proxies completely atm. I ran a poll the other day, and it looks like 50% of React-Redux users are still targeting ES5 environments: https://twitter.com/acemarke/status/1111050066532265984 .

faceyspacey commented 5 years ago

Not all proxy features are needed. And what is needed can be addressed under the hood (aka polyfilled), so that IE11 can work with no different work required of the end user. Proxy support isn’t in fact a barrier.

To be clear, not all of proxy features can be pollyfilled. But all that’s needed for this solution can be polyfilled.

theKashey commented 5 years ago

https://github.com/GoogleChrome/proxy-polyfill - only ownKeys trap is not supported

MrWolfZ commented 5 years ago

@faceyspacey @theKashey there is one edge case where the polyfill doesn't work: if you have an optional property on the root state and add that property later on it will not notice this new property. Here is a reproduction. In this example, if you click "Add property", it will not cause Child to re-render.

However, this issue is very unlikely to surface in a real application, since it only happens for optional properties on the root state. For more nested properties it will simply be less performant, since it will stop the access detection chain at the parent of the optional property and then re-render Child whenever the parent changes. Here is an example showing this. In this example, the render count will not increase when clicking "Add property 2" without the polyfill but will increase with the polyfill.

Jessidhia commented 5 years ago

(You can force the polyfill to be used even in modern browsers by just adding a <script>delete Proxy</script> to public/index.html's <head/>.)

MrWolfZ commented 5 years ago

@Jessidhia thank you for the hint. I tried deleting the global proxy object inside index.js but couldn't get it to work properly. With your suggestion it works perfectly, so I adjusted the sandboxes.

theKashey commented 5 years ago

@MrWolfZ - the example is awesome - short and sound. And I have no idea why it's not working.


So - if you are accessing some prop, which does not exist - the access would not be recorded, thus the changes in that prop would be ignored. That might be a big issue, actually, @ctrlplusb already created the same issue, but with another way to reproduce. I am afraid, but any change in object keys should be considered as a major update. And, huh, that would cause some performance implications.

markerikson commented 5 years ago

@MrWolfZ : "optional properties on the root state" sounds exactly like a typical "dynamically loaded code-split reducers" scenario.

theKashey commented 5 years ago

I don't see any technical way to solve this case without proxies, but there is a non-technical way.

Honestly - the first variant is more acceptable.

markerikson commented 5 years ago

General Status Update

I've spent the last few hours doing research. I've re-read #1063 and this issue, put together a list of existing unofficial Redux hooks libs, looked at some of the code snippets pasted in these threads, and glanced through the source of reactive-react-redux and proxyequal.

I don't have any grand insights or solutions atm - I'm still trying to absorb a lot of info.

Notes and Thoughts

My immediate random thoughts, in no particular order:

Possible Path Forward

So here's a hypothetical suggested path towards actually building this:

Thoughts?

theKashey commented 5 years ago

Yeah. I have one Thought, not related to this topic, but better be answered here.

In short - I have no idea how and why people are ok with redux. Unless you are an experienced developer with quite a special mindset, it's quite hard to create a proper boilerplate around selection from state and derived data creating in mapStateToProps.

To be clear - the problem is not with redux, the problem is with memoization it requires to live happily ever after without cascade updates.

Reselect is fragile, and not component-architecture compatible by design. re-reselect had never been an option for me. React.useMemo could be a great solution, but it has to live within React render cycle, which affects API. I've tried my best - kashe as a solution for derived data generation/memoization and memoize-state (proxyequal) to remove than engeneering aspect from memoization.

So - my thoughts are simple - we need not a hook-based API, but an API there is would be or harder to make a mistake, or easier to write more sound code.

99% redux customers are doing it wrong, not knowing/checking what they are doing, or where/why memoization fails. Every time I convince someone to install why-did-you-update-redux and actually test "quality" of their "redux" - it's 🀯. Using custom equality check to perform deep comparison in reselect or areMergedPropsEqual - easy. Not using any memoization at all - even easier. Babel optional chaining (returning a new "empty" object every time) - a new trend.

A generic hook solution, like redux-react-hook is good, and looks quite similar to the "old" redux, but useMappedState like a forcing you to continue use hooks, especially useMemo, inside mapStateToProps, while you cant. It's +10 WTF per minute.

What if _first try to create an addition to react-dev-tools(aka reselect-tools), to help you build/visualize selectors according to your factual state and factual usage (dev tools could track both), and then build hooks API__ in a way to easier utilize results of such tool.

PS: your concerns about proxyequal size are not quite correct. It's a low-level library which does not handle edge cases. Memoize-state, which build on top of it to handle those cases is twice bigger.

faceyspacey commented 5 years ago

@theKashey amen, preach!

Basically, the only way the bell curve of developers (according to skill level) will ever be able to build properly rendering apps is through a proxy-based connectless future. Having to configure connect is also an absolute waste of developer time, given there is a path forward where we don't need it. connect will inevitably die, and the sooner the better.

I've been talkin about this for 2 years. It's time to level up the Redux world. I personally have chosen to focus on other things, FYI, because this is inevitable. It's like how Marc Andreesen talks about industries like the music industry (and now many others) inevitably being toppled: "technology is like water; it inevitably fills all the gaps and there's nothing we can do to stop it; we're better off supporting the natural direction of growth." That's not the exact quote, but you get the idea: we're best off facilitating a proxy-based connectless future.

So the most important thing the Redux ecosystem needs isn't a "hooks" API. HOCs still work beautifully. What needs to be determined is if hooks truly provide much value here (which they don't) or if we're just doing things because the React team opened up a new shiney API.

The thing is this:

If we want to "be like water" (this time in the Bruce Lee way), and hit a few birds with one stone, react-redux 8 should be hooks + proxies. 7 remains for everyone that can't support proxies yet.

Lastly, there are workarounds for supporting proxies. Namely what MobX always did where keys must be known upfront, as @theKashey mentioned. So in 8.5, perhaps that's introduced so even IE11 can make use of the transparent proxy-based approach. @theKashey has a few ideas how to handle this. But obsessing over it is not the path of the people that actually get this done. Build it first without supporting the non-proxy approach, and then circle back, like the Vue team is doing with Vue 3:

yes, out the gate, Vue 3 will not support IE11 and browsers that dont support Proxies.

In conclusion, the best use of available energies is toward helping @dai-shi and @theKashey optimize their proxy-based approach in their 2 libs:

They are doing something truly groundbreaking. I usually don't poke my head out in these threads--so the whole purpose of my post here is to make it known how important their work is. The other hooks approaches are all following traditional approaches. Where's the revolutionary spirit? That's the whole point of software after all.

There's some remaining perf issues, but it's clearly achievable. What those 2 have done has proved a lot already. The perf for a great number of cases is comparable to react-redux 7. The one that's sub-par is a contrived case. It needs to be addressed regardless. The point is: we're close, and would be closer if more from the community got serious about this approach, rather than treating it like some far off thing. Of course current react-redux users are tied to IE11. But it's a totally different story for apps starting today. Not offering a futuristic approach, and not making that the main focus, will only mean Redux falls more and more out of favor. Bold action is required. Long live Redux!

markerikson commented 5 years ago

@theKashey , @faceyspacey : fwiw, I definitely appreciate the work you and @dai-shi are doing investigating various Proxy-based approaches, especially given that I don't have time to try any of that myself.

That said, given React-Redux's "official" status, and the fact that it's one of the single most widely used libraries in the React ecosystem, we have to carefully balance a lot of different concerns: API design, browser compatibility, familiarity, teachability, community usages, versioning, and more. That means we do have to move a bit slowly and favor a conservative approach overall.

There's a very clear interest from the community in having us add some kind of hooks-based API, hence this issue. connect() is extremely well known, and there's a straight path from an API like mapState to useSelect() or similar. Adding something like useSelect() now doesn't preclude us from adding more APIs down the road.

But, anything we add now does have to continue to work and be supported going forwards. I refuse to put out new public APIs without ensuring they're tested and rock-solid, and I'm not going to force our users to keep bumping major versions (or even worse, break their code) because we did something bad. I feel bad enough about the v6 -> v7 change as it is - I'm the one who pushed for the v6 context-based state propagation. Granted, at the time it really did look like the way to go, but the churn here is basically my fault for choosing an approach that didn't pan out.

I agree that IE11 is (slowly) on the way out, and that use of Proxies is only going to increase over time. I think there's some great possibilities around using them, particularly for React-Redux. But, I'm not ready to jump that chasm yet.

I'd encourage you to continue your experiments and to work out the rough edges around these approaches. I've already been keeping an eye on what y'all have been doing, and I'm excited to see how it all turns out. When the time comes to try pulling some of those ideas into React-Redux itself, I will gladly learn from what you've done, give all of you full credit for any inspiration we take, and ask you to help contribute if possible.

Until then, the most straightforward path to getting something out the door is to build on the hooks-based code we've got in v7 right now, and provide hooks APIs that closely resemble use of connect().

faceyspacey commented 5 years ago

Mark, I updated my post, make sure to check out what Vue 3 is doing where they wont initially support proxies:

https://medium.com/the-vue-point/plans-for-the-next-iteration-of-vue-js-777ffea6fabf

I wouldn't be closed off to a react-redux going a similar route sooner than it seems you're currently considering. Vue after all is the whole framework, whereas react-redux is just one choice of many libs you can use with React.

There's 3 ways react-redux could get serious about it sooner:

Basically it would be nice if react-redux got serious about supporting this endeavor sooner than later. Right now @dai-shi and @theKashey need a lot more eyeballs than yours and mine.

Everyone else, HELP WANTED HERE:

https://github.com/dai-shi/reactive-react-redux

theKashey commented 5 years ago

We need some established pattern for redux, and right now it does exist. Then we could try to create an eslint plugin, like react-team created for hooks(really - that's the deal breaker) and mitigate the majority "πŸ‘Ž"s.

So - let's focus not on the implementation details, but on the sound API, it would be possible to name as a pattern and create an infrastructure around.

My personal opinion - I love to separate concerns as much as possible (ie Smart/Dump) to make tests/storybooks easier. Right now all hooks realization are tightly coupled, they just form one big component, and does not answer this call.

So - Hooks vs Tests: the engage.

ricokahler commented 5 years ago

So the most important thing the Redux ecosystem needs isn't a "hooks" API. HOCs still work beautifully. What needs to be determined is if hooks truly provide much value here (which they don't) or if we're just doing things because the React team opened up a new shiney API.

@faceyspacey I don't necessarily agree with this. I care a lot about React hooks and a redux API because it simplifies composing together and reusing reactive dependency injectors (i.e. things that provide data + have the ability to case a re-render). Historically, I've been composing reactive dependency injectors via HOCs and that made the rest of my team scratch their heads e.g.

const Container = compose(
  withRouter,// get the `location` from react-router
  connect(/* ... */), // use the location + redux state to derive props
  lifecycle({
    componentDidUpdate() {
      // watch for changes in those props and refetch if necessary
    }
  }), 
)(PresentationalComponent);

export default Container;

with hooks, composing reactive dependency injectors looks much better/is more readable and useEffect is particular helpful

export default function Container() {
  const { location } = useRouter();
  const exampleState = useRedux(/* ... */);

  const calculatedProp = useMemo(() => {
    return // ...
  }, [location, exampleState]);

  useEffect(() => {
    // refetch data or similar
  }, [calculatedProp]);

  return <PresentationalComponent exampleProp={calculatedProp} />;
}

I think the above is a very "react" way of addressing the concerns @theKashey has with redux memoization. Don't get me wrong, @theKashey is 100% correct with the pains of memoization but hooks with useMemo might also be a good way to deal with it??

It's not fool proof but at least better than the current state of composing HOCs.


I like @markerikson proposed possible path forward simply because it gives me hooks and it's great incremental approach to improved patterns.

theKashey commented 5 years ago

hooks with useMemo might also be a good way to deal with it??

That's a problem! They could not! 99% redux-hook implementation uses subscription mechanics to react to store changes and skip unnecessary updates, so your useMemo would be called by redux out of React rendering cycle. "WTF!", you can't use hooks in redux-hooks API, which is a bit controversial.