Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@wordpress/data: Introduce new custom useDispatch react hook #15896

Merged
merged 22 commits into from
Jun 3, 2019
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,52 @@ _Parameters_

- _plugin_ `Object`: Plugin object.

<a name="useDispatch" href="#useDispatch">#</a> **useDispatch**

A custom react hook returning the current registry dispatch actions creators.

Note: The component using this hook must be within the context of a
RegistryProvider.

_Usage_

This illustrates a pattern where you may need to retrieve dynamic data from
the server via the `useSelect` hook to use in combination with the dispatch
action.

```jsx
const { useDispatch, useSelect } = wp.data;
const { useCallback } = wp.element;

function Button( { onClick, children } ) {
return <button type="button" onClick={ onClick }>{ children }</button>
}

const SaleButton = ( { children } ) => {
const { stockNumber } = useSelect(
( select ) => select( 'my-shop' ).getStockNumber()
);
const { startSale } = useDispatch( 'my-shop' );
const onClick = useCallback( () => {
const discountPercent = stockNumber > 50 ? 10: 20;
startSale( discountPercent );
}, [ stockNumber ] );
return <Button onClick={ onClick }>{ children }</Button>
}

// Rendered somewhere in the application:
//
// <SaleButton>Start Sale!</SaleButton>
```

_Parameters_

- _storeName_ `?string`: Optionally provide the name of the store from which to retrieve action creators. If not provided, the registry.dispatch function is returned instead.

_Returns_

- `Function`: A custom react hook.

<a name="useRegistry" href="#useRegistry">#</a> **useRegistry**

A custom react hook exposing the registry context for use.
Expand Down
2 changes: 2 additions & 0 deletions packages/data/src/components/use-dispatch/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as useDispatch } from './use-dispatch';
export { default as useDispatchWithMap } from './use-dispatch-with-map';
71 changes: 71 additions & 0 deletions packages/data/src/components/use-dispatch/use-dispatch-with-map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { mapValues } from 'lodash';

/**
* WordPress dependencies
*/
import { useMemo, useRef, useEffect, useLayoutEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import useRegistry from '../registry-provider/use-registry';

/**
* Favor useLayoutEffect to ensure the store subscription callback always has
* the dispatchMap from the latest render. If a store update happens between
* render and the effect, this could cause missed/stale updates or
* inconsistent state.
*
* Fallback to useEffect for server rendered components because currently React
* throws a warning when using useLayoutEffect in that environment.
*/
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;

/**
* Custom react hook for returning aggregate dispatch actions using the provided
* dispatchMap.
*
* Currently this is an internal api only and is implemented by `withDispatch`
*
* @param {Function} dispatchMap Receives the `registry.dispatch` function as
* the first argument and the `registry` object
* as the second argument. Should return an
* object mapping props to functions.
* @param {Array} deps An array of dependencies for the hook.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What purpose does deps serve if this is an internal hook, and the only place we use it, we always pass an empty array?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. Right now with this being an internal only implementation it's obviously an unneeded variable. There may be potential for this hook being exposed at some point as a public api (if use-case demonstrates need) in which case there is utility for the deps arg.

* @return {Object} An object mapping props to functions created by the passed
* in dispatchMap.
*/
const useDispatchWithMap = ( dispatchMap, deps ) => {
const registry = useRegistry();
const currentDispatchMap = useRef( dispatchMap );

useIsomorphicLayoutEffect( () => {
currentDispatchMap.current = dispatchMap;
} );

return useMemo( () => {
const currentDispatchProps = currentDispatchMap.current(
registry.dispatch,
registry
);
return mapValues(
currentDispatchProps,
( dispatcher, propName ) => {
if ( typeof dispatcher !== 'function' ) {
// eslint-disable-next-line no-console
console.warn(
`Property ${ propName } returned from dispatchMap in useDispatchWithMap must be a function.`
);
}
return ( ...args ) => currentDispatchMap
.current( registry.dispatch, registry )[ propName ]( ...args );
}
);
}, [ registry, ...deps ] );
};

export default useDispatchWithMap;
53 changes: 53 additions & 0 deletions packages/data/src/components/use-dispatch/use-dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Internal dependencies
*/
import useRegistry from '../registry-provider/use-registry';

/**
* A custom react hook returning the current registry dispatch actions creators.
*
* Note: The component using this hook must be within the context of a
* RegistryProvider.
*
* @param {?string} storeName Optionally provide the name of the store from
* which to retrieve action creators. If not
* provided, the registry.dispatch function is
* returned instead.
*
* @example
* This illustrates a pattern where you may need to retrieve dynamic data from
* the server via the `useSelect` hook to use in combination with the dispatch
* action.
*
* ```jsx
* const { useDispatch, useSelect } = wp.data;
* const { useCallback } = wp.element;
*
* function Button( { onClick, children } ) {
* return <button type="button" onClick={ onClick }>{ children }</button>
* }
*
* const SaleButton = ( { children } ) => {
* const { stockNumber } = useSelect(
* ( select ) => select( 'my-shop' ).getStockNumber()
* );
* const { startSale } = useDispatch( 'my-shop' );
* const onClick = useCallback( () => {
* const discountPercent = stockNumber > 50 ? 10: 20;
* startSale( discountPercent );
* }, [ stockNumber ] );
* return <Button onClick={ onClick }>{ children }</Button>
* }
*
* // Rendered somewhere in the application:
* //
* // <SaleButton>Start Sale!</SaleButton>
* ```
* @return {Function} A custom react hook.
*/
const useDispatch = ( storeName ) => {
const { dispatch } = useRegistry();
return storeName === void 0 ? dispatch : dispatch( storeName );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First time I see === void 0. I'd have done !! storeName personally. Curious to learn more here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ya !! storeName could have been used here. Using void 0 is just an old habit I sometimes slip into when I want to check against the undefined primitive value. I don't know if it's still the case with modern browsers, but it was possible for the undefined global to be overwritten so it was just a protection.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it's still the case with modern browsers, but it was possible for the undefined global to be overwritten so it was just a protection.

It's not been possible to override in any browser which conforms to ES5 and newer (IE9+).

https://www.ecma-international.org/ecma-262/5.1/#sec-15.1.1.3

A future maintainer most certainly will struggle to under what this code is doing. Not a big issue, but in general we should feel fine to compare against undefined.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Babel might even take care of it too.

Copy link
Contributor Author

@nerrad nerrad Jun 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel really old all of a sudden 😉

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Babel might even take care of it too.

I was curious about this, and it doesn't seem like Babel does, but Uglify will convert it, yes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, maybe for the older targets?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely because void 0 is 3 fewer characters than undefined 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely because void 0 is 3 fewer characters than undefined

Ha! That's my new reason.

};

export default useDispatch;
74 changes: 12 additions & 62 deletions packages/data/src/components/with-dispatch/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
/**
* External dependencies
*/
import { mapValues } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import { pure, createHigherOrderComponent } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { RegistryConsumer } from '../registry-provider';
import { useDispatchWithMap } from '../use-dispatch';

/**
* Higher-order component used to add dispatch props using registered action creators.
Expand Down Expand Up @@ -79,61 +73,17 @@ import { RegistryConsumer } from '../registry-provider';
* @return {Component} Enhanced component with merged dispatcher props.
*/
const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent(
( WrappedComponent ) => {
class ComponentWithDispatch extends Component {
constructor( props ) {
super( ...arguments );

this.proxyProps = {};

this.setProxyProps( props );
}

proxyDispatch( propName, ...args ) {
// Original dispatcher is a pre-bound (dispatching) action creator.
mapDispatchToProps( this.props.registry.dispatch, this.props.ownProps, this.props.registry )[ propName ]( ...args );
}

setProxyProps( props ) {
// Assign as instance property so that in subsequent render
// reconciliation, the prop values are referentially equal.
// Importantly, note that while `mapDispatchToProps` is
// called, it is done only to determine the keys for which
// proxy functions should be created. The actual registry
// dispatch does not occur until the function is called.
const propsToDispatchers = mapDispatchToProps( this.props.registry.dispatch, props.ownProps, this.props.registry );
this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => {
if ( typeof dispatcher !== 'function' ) {
// eslint-disable-next-line no-console
console.warn( `Property ${ propName } returned from mapDispatchToProps in withDispatch must be a function.` );
}
// Prebind with prop name so we have reference to the original
// dispatcher to invoke. Track between re-renders to avoid
// creating new function references every render.
if ( this.proxyProps.hasOwnProperty( propName ) ) {
return this.proxyProps[ propName ];
}

return this.proxyDispatch.bind( this, propName );
} );
}

render() {
return <WrappedComponent { ...this.props.ownProps } { ...this.proxyProps } />;
}
( WrappedComponent ) => pure(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure this pure call is not really necessary (and I suspect the small decline of performance that you noticed is coming from it)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh good point, I'll try without it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 6500117. The new 3 run average for the new withDispatch after pure being removed is:

Average time to load: 6590ms
Average time to DOM content load: 6041ms
Average time to type character: 99ms
Slowest time to type character: 234ms
Fastest time to type character: 71ms

So ya, it does look like there was some unnecessary overhead added by using pure here.

( ownProps ) => {
const mapDispatch = ( dispatch, registry ) => mapDispatchToProps(
dispatch,
ownProps,
registry
);
const dispatchProps = useDispatchWithMap( mapDispatch, [] );
return <WrappedComponent { ...ownProps } { ...dispatchProps } />;
}

return ( ownProps ) => (
<RegistryConsumer>
{ ( registry ) => (
<ComponentWithDispatch
ownProps={ ownProps }
registry={ registry }
/>
) }
</RegistryConsumer>
);
},
),
'withDispatch'
);

Expand Down
1 change: 1 addition & 0 deletions packages/data/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
useRegistry,
} from './components/registry-provider';
export { default as useSelect } from './components/use-select';
export { useDispatch } from './components/use-dispatch';
export {
AsyncModeProvider as __experimentalAsyncModeProvider,
} from './components/async-mode-provider';
Expand Down