Skip to content

Discussion: Potential hooks API design #1179

@markerikson

Description

@markerikson
Contributor

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.

Activity

esamattis

esamattis commented on Feb 8, 2019

@esamattis
Contributor

Based on my experiments I've come up with following wishlist for the official redux hooks api:

Provide low level primitives

  • useMapState() - with setState like === check based render bailout
    • I think think this is essential for empowering userland wrappers
  • useDispatch() - just return the dispatch function
  • useStore() - Too powerful?

Maybe these higher level APIs

  • useActionCreators() takes an actions creators object and binds it to dispatch (memoized)
  • Variation of useMapState() with shallow equal check
  • useSelector() - reselect like helper
    • This might mitigate the need for the shallow equal check in the useMapState primitive
    • I have an example implementation of this in my redux-hooks lib

Designed for good TypeScript support. TypeScript is growing like crazy and the HOC based connector is and has been pain for TypeScript users. This is an awesome opportunity to serve TS users propertly.


For the curious I would engourage you to try the hook bindings here

https://github.com/epeli/redux-hooks

It's more than a toy as it attempts to actually implement all the performance requirements needed for real world usage and I would really appreciate any feedback because feedback on it would help the design of these official ones too.

jcestibariz

jcestibariz commented on Feb 8, 2019

@jcestibariz

There's a similar project in the Facebook incubator: https://github.com/facebookincubator/redux-react-hook

Jessidhia

Jessidhia commented on Feb 8, 2019

@Jessidhia

Personally I lean towards not providing an API that's expected to produce objects at all, but rather a separate invocation for each selector or action creator you want to use.

// user augments this from outside,
// or we need some other trick to pass out-of-band type information
interface StoreState {}

// 2nd argument for these is like a useMemo argument,
// but defaults to [1st argument]. The reasoning is that
// you usually use selectors that were defined outside the
// component if they're 1-ary / creators defined outside
// the component if they're 0-ary.

// one useSelector per value you want to get
// it, of course, also implicitly depends on the
// context store's getState().
function useSelector<T>(
  selector: (state: StoreState) => T,
  deps?: ReadonlyArray<unknown>
): T

// these return their argument but bound to dispatch
// the implementation is just a memoized version of something like
//   return typeof arg1 === 'function'
//     ? (...args) => dispatch(arg1(...args))
//     : () => dispatch(arg1)
// but the types are way more complicated

// first overload for thunk action creators
function useAction<
  T extends (
    ...args: any[]
  ) => (dispatch: any, getState: () => StoreState, extraArgument: any) => any
>(
  actionCreator: T,
  deps?: ReadonlyArray<unknown>
): T extends (...args: infer A) => (...args: any[]) => infer R
  ? (...args: A) => R
  : never
// second overload for regular action creators
function useAction<T extends (...args: any[]) => any>(
  actionCreator: T,
  deps?: ReadonlyArray<unknown>
): T
// lastly expect a regular action
function useAction<T extends { type: string }>(
  action: T,
  deps?: ReadonlyArray<unknown>
): () => T

This does have the benefit of never giving you direct access to dispatch, though! Always using bound dispatchers feels way more ergonomic to me. If you want to improve usability further (such as binding certain arguments of a multi-argument action creator) you could always wrap either the input or the output in another useMemo.

This 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.

I had an idea to share subscriptions between useSelector calls but it feels redundant:

// fake const, only exists for creating a named type
declare const __SubscriptionToken: unique symbol
type Subscription = typeof __SubscriptionToken

// creates a ref (what the Subscription actually is) and returns it
function useSubscription(): Subscription
// adds itself to a list of selectors the subscription updates which is...
// ...reimplementing subscriptions on top of a subscription?
function useSelector<T>(
  subscription: Subscription,
  selector: (state: StoreState) => T
): T

The complicated part is when you have a subscription value that depends on the result of other subscriptions -- but you only need one of the subscriptions to update for the component to rerender, and at that point the other selectors will be re-invoked when the useSelector is reached.

If you really want to you can also just return an object but then you have to handle memoization yourself and you can't use useMemo (directly) for it.

const mySelector = useMemo(() => {
  let previous
  return (state: StoreState) => {
    const result = { a: state.a, b: state.a && state.b }
    if (!previous || previous.a !== state.a || previous.b !== state.b) {
      previous = result
    }
    return previous
  }
}, [])

const { a, b } = useSelector(mySelector)
Jessidhia

Jessidhia commented on Feb 8, 2019

@Jessidhia

I also thought of a possible effect-like API but it feels dirty to use. It's "too global" as it's not necessarily coupled to your component; or even if it is, what would it mean to have multiple copies of this component mounted?

function useStoreEffect(
  effect: (state: StoreState) => void | (() => void | undefined),
  // defaults to () => undefined
  deps?: (state: StoreState) => ReadonlyArray<unknown> | undefined
): void

It's like a useEffect but it'd also be invoked outside the React render cycle if the store state changed. Probably too low-level / dangerous, but is roughly the equivalent of getting the store from the context and calling subscribe yourself.

chris-pardy

chris-pardy commented on Feb 8, 2019

@chris-pardy

Thinking about this as well and would suggest:

  • useSelect which would copy the select effect API from sagas. That would let you use your existing map state to props functions with no real changes.
  • useDispatch which would wrap a call to bindActionCreators letting you pass either an action creator, or object to create dispatch functions.

Both hooks would use an identity function as the default first argument so the effect of calling them without arguments would be to return the entire state, or a dispatch function respectively.

I think there's lots of room for building on top of these two base hooks but why not start super simple and let the community evolve some patterns?

Partial typescript API (doing this from my phone, so excuse any oddities)

interface useSelect {
  <S>(): S;
  <S, R>(selector: (state: S) => R): R;
  <S, P, R>(selector: (state: A, params: P, ...args: any[]) => R, params: P, ...args: any[]): R
}

interface useDispatch {
  (): Dispatch<AnyAction>;
  <A extends Action = AnyAction>(actionCreator: ActionCreator<A>): ActionCreator<A>;
  <O extends ActionCreatorMap>(actionCreators: O): O;
}

Full implementation (sans tests, examples, etc.) in this Gist - https://gist.github.com/chris-pardy/6ff60fdae7404f5745a865423989e0db

esamattis

esamattis commented on Feb 8, 2019

@esamattis
Contributor

Here's an interesting API idea: Passive state mapping hook that does not subscribe to store changes at all. It only executes when the deps change.

Implementation is basically this:

function usePassiveMapState(mapState, deps) {
    const store = useStore();
    return useMemo(() => mapState(store.getState()), deps);
}

It makes no sense as a standalone hook but when combined with an active hook it opens up a whole new world of optimization techniques.

Example:

const shop = useMapState(state => state.shops[shopId]);

// Shop products is updated only when the shop itself
// has been updated. So this generates the productNames
// array only when the shop has updated. 
const productNames = usePassiveMapState(
    state => state.shop[shopId].products.map(p => p.name),
    [shop],
);

I don't think you can get more efficient than that. Pretty readable too.

Pretty much a microptimization but avoiding new references can save renders downstream from pure components.

This is available for testing here.

adamkleingit

adamkleingit commented on Feb 9, 2019

@adamkleingit

Personally I lean towards not providing an API that's expected to produce objects at all, but rather a separate invocation for each selector or action creator you want to use.

// user augments this from outside,
// or we need some other trick to pass out-of-band type information
interface StoreState {}

// 2nd argument for these is like a useMemo argument,
// but defaults to [1st argument]. The reasoning is that
// you usually use selectors that were defined outside the
// component if they're 1-ary / creators defined outside
// the component if they're 0-ary.

// one useSelector per value you want to get
// it, of course, also implicitly depends on the
// context store's getState().
function useSelector<T>(
  selector: (state: StoreState) => T,
  deps?: ReadonlyArray<unknown>
): T

// these return their argument but bound to dispatch
// the implementation is just a memoized version of something like
//   return typeof arg1 === 'function'
//     ? (...args) => dispatch(arg1(...args))
//     : () => dispatch(arg1)
// but the types are way more complicated

// first overload for thunk action creators
function useAction<
  T extends (
    ...args: any[]
  ) => (dispatch: any, getState: () => StoreState, extraArgument: any) => any
>(
  actionCreator: T,
  deps?: ReadonlyArray<unknown>
): T extends (...args: infer A) => (...args: any[]) => infer R
  ? (...args: A) => R
  : never
// second overload for regular action creators
function useAction<T extends (...args: any[]) => any>(
  actionCreator: T,
  deps?: ReadonlyArray<unknown>
): T
// lastly expect a regular action
function useAction<T extends { type: string }>(
  action: T,
  deps?: ReadonlyArray<unknown>
): () => T

This does have the benefit of never giving you direct access to dispatch, though! Always using bound dispatchers feels way more ergonomic to me. If you want to improve usability further (such as binding certain arguments of a multi-argument action creator) you could always wrap either the input or the output in another useMemo.

This 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.

I had an idea to share subscriptions between useSelector calls but it feels redundant:

// fake const, only exists for creating a named type
declare const __SubscriptionToken: unique symbol
type Subscription = typeof __SubscriptionToken

// creates a ref (what the Subscription actually is) and returns it
function useSubscription(): Subscription
// adds itself to a list of selectors the subscription updates which is...
// ...reimplementing subscriptions on top of a subscription?
function useSelector<T>(
  subscription: Subscription,
  selector: (state: StoreState) => T
): T

The complicated part is when you have a subscription value that depends on the result of other subscriptions -- but you only need one of the subscriptions to update for the component to rerender, and at that point the other selectors will be re-invoked when the useSelector is reached.

If you really want to you can also just return an object but then you have to handle memoization yourself and you can't use useMemo (directly) for it.

const mySelector = useMemo(() => {
  let previous
  return (state: StoreState) => {
    const result = { a: state.a, b: state.a && state.b }
    if (!previous || previous.a !== state.a || previous.b !== state.b) {
      previous = result
    }
    return previous
  }
}, [])

const { a, b } = useSelector(mySelector)

I'm for this API a lot. On occasions, you need the dispatch (for dynamic actions that can't be treated with actionCreators), so I would add useDispatch.
I think this library should focus on the basic API to allow developers to extend with custom hooks. So caching/side-effect etc. should not be included

chris-pardy

chris-pardy commented on Feb 12, 2019

@chris-pardy

Personally I lean towards not providing an API that's expected to produce objects at all, but rather a separate invocation for each selector or action creator you want to use.

100% Agree on this, I think this is the direction things should generally be going with hooks, and it seems to jive with what facebook did with useState.

// these return their argument but bound to dispatch
// the implementation is just a memoized version of something like
//   return typeof arg1 === 'function'
//     ? (...args) => dispatch(arg1(...args))
//     : () => dispatch(arg1)
// but the types are way more complicated

// first overload for thunk action creators
function useAction<
  T extends (
    ...args: any[]
  ) => (dispatch: any, getState: () => StoreState, extraArgument: any) => any
>(
  actionCreator: T,
  deps?: ReadonlyArray<unknown>
): T extends (...args: infer A) => (...args: any[]) => infer R
  ? (...args: A) => R
  : never
// second overload for regular action creators
function useAction<T extends (...args: any[]) => any>(
  actionCreator: T,
  deps?: ReadonlyArray<unknown>
): T
// lastly expect a regular action
function useAction<T extends { type: string }>(
  action: T,
  deps?: ReadonlyArray<unknown>
): () => T

This feels overwrought, I suggested a simple wrapper around bindActionCreators but even if that's not exactly the API, just getting a dispatch function feels like the right level of simplicity. Something that needs to handle Thunk action creators feels overwrought.

markerikson

markerikson commented on Feb 12, 2019

@markerikson
ContributorAuthor

I think it's worth going all the way back to issue #1 as a reference. Dan laid out a list of constraints that the new in-progress React-Redux API would need to follow. Here's that list:

Common pain points:

  • Not intuitive how way to separate smart and dumb components with <Connector>, @connect
  • You have to manually bind action creators with bindActionCreators helper which some don't like
  • Too much nesting for small examples (<Provider>, <Connector> both need function children)

Let's go wild here. Post your alternative API suggestions.

They should satisfy the following criteria:

  • Some component at the root must hold the store instance. (Akin to <Provider>)
  • It should be possible to connect to state no matter how deep in the tree
  • It should be possible to select the state you're interested in with a select function
  • Smart / dumb components separation needs to be encouraged
  • There should be one obvious way to separate smart / dumb components
  • It should be obvious how to turn your functions into action creators
  • Smart components should probably be able to react to updates to the state in componentDidUpdate
  • Smart components' select function needs to be able to take their props into account
  • Smart component should be able to do something before/after dumb component dispatches an action
  • We should have shouldComponentUpdate wherever we can

Obviously a lot of that isn't exactly relevant for hooks, but which ones are useful, and what other constraints might be good goals?

chris-pardy

chris-pardy commented on Feb 12, 2019

@chris-pardy

Feels like most of those original criteria are still relevant. I would rephrase:

  • Smart components should probably be able to react to updates to the state in componentDidUpdate
  • We should have shouldComponentUpdate wherever we can

As "shouldn't impact performance".

I'm concerned that hooks would be the ultimate foot-gun for:

  • Smart / dumb components separation needs to be encouraged

But I'm not sure there's a good solution other than lots of evangelizing about the benefits of separation of concerns.

ricokahler

ricokahler commented on Feb 12, 2019

@ricokahler
Contributor
  • Smart / dumb components separation needs to be encouraged

I think this actually becomes less clear with hooks regardless. I think hooks makes it easier to understand and separate smart container vs dumb presentational components but the effort has to be conscious.


PresentationalComponent.js

export default function PresentationalComponent () {
  return // ...
}

connect HOC

// connect container
import PresentationalComponent from 'blah/PresentationalComponent';

export default connect(
  // etc...
)(PresentationalComponent);

hooks

Also addressing

There should be one obvious way to separate smart / dumb components

This is it for hooks imo:

// hooks container
import PresentationalComponent from 'blah/PresentationalComponent';

/** with hooks, you need to manually create a "container" component */
export default function Container() {
  const props = useReduxState(/* ... */); // not proposing this API btw, just a place holder
  const action = useReduxAction(/* ... */);

  return <PresentationalComponent {...props} onEvent={action} />;
}

Because you have to manually create the container component, it's less obvious that you should separate container and presentational components. For example, some users will probably think, "why not just put useReduxState in the presentational component"?

export default function PresentationalComponent () {
  const props = useReduxState(/* ... */); // not proposing this API btw, just a place holder
  const action = useReduxAction(/* ... */);

  return // ...
}

I still think the separation of container and presentational components is important but I'm not sure it's possible to create an API where we can make it obvious to encourage the separation.

Maybe this is a problem solely docs can solve?

adamkleingit

adamkleingit commented on Feb 12, 2019

@adamkleingit

When using custom hooks predictability is an issue on all fronts.
If you see:

const user = useCurrentUser();

in your component, it's not straightforward whether this component is aware of Redux or not, unless you enforce conventions in your team, like:

const user = useCurrentUser();
saboya

saboya commented on Feb 14, 2019

@saboya

@adamkleingit Not knowing that the component uses Redux or not is actually better for your business logic design. Redux is an implementation detail. If your hook is called useCurrentUser, the only thing that the hook consumer should rely on is the fact that the current user will be returned. If later on you decide to switch Redux for something else, you only have to work on your custom hooks, and nowhere else.

199 remaining items

MrLoh

MrLoh commented on Apr 22, 2019

@MrLoh

@Dudeonyx I don't think this would work, as it would lead to a serenader as soon as the screen becomes inactive and everything goes to an empty/loading state. Nevertheless it should be possible to create a special memoized selector that saves and returns the last state when the screen is not inView

dimaqq

dimaqq commented on Apr 24, 2019

@dimaqq

Here's a crazy idea:

Provide syntax sugar with exact same behaviour as mapStateToProps, mapDispatchToProps.

P.S. I'm not experienced enough to tell if the latter is better served by context instead.

MrLoh

MrLoh commented on Apr 24, 2019

@MrLoh

@dimaqq Discussion has moved to #1252 but that will probably not happen, the nice thing is, it's super easy to build your own custom hooks with whatever flavor you need based on the redux hooks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @timdorr@ryansully@saboya@Jessidhia@faceyspacey

        Issue actions

          Discussion: Potential hooks API design · Issue #1179 · reduxjs/react-redux