From 97806141c43af90f597c3ed1196d79a7442166a8 Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Mon, 3 Jun 2019 06:12:33 -0400 Subject: [PATCH] @wordpress/data: Introduce new custom `useDispatch` react hook (#15896) This pull contains the work exposing a new `useDispatch` hook on the @wordpress/data package. Also, `withDispatch` has been refactored internally to use a new (internal only) `useDispatchWithMap` hook. --- packages/data/CHANGELOG.md | 2 + packages/data/README.md | 107 ++++++++++---- .../data/src/components/use-dispatch/index.js | 2 + .../use-dispatch/test/use-dispatch.js | 137 ++++++++++++++++++ .../use-dispatch/use-dispatch-with-map.js | 71 +++++++++ .../components/use-dispatch/use-dispatch.js | 53 +++++++ .../src/components/with-dispatch/index.js | 135 ++++++----------- .../components/with-dispatch/test/index.js | 104 ++++++++----- packages/data/src/index.js | 1 + 9 files changed, 459 insertions(+), 153 deletions(-) create mode 100644 packages/data/src/components/use-dispatch/index.js create mode 100644 packages/data/src/components/use-dispatch/test/use-dispatch.js create mode 100644 packages/data/src/components/use-dispatch/use-dispatch-with-map.js create mode 100644 packages/data/src/components/use-dispatch/use-dispatch.js diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 391d68abe4c463..6851e89823c6ac 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -3,10 +3,12 @@ ### New Feature - Expose `useSelect` hook for usage in functional components. ([#15737](https://github.com/WordPress/gutenberg/pull/15737)) +- Expose `useDispatch` hook for usage in functional components. ([#15896](https://github.com/WordPress/gutenberg/pull/15896)) ### Enhancements - `withSelect` internally uses the new `useSelect` hook. ([#15737](https://github.com/WordPress/gutenberg/pull/15737). **Note:** This _could_ impact performance of code using `withSelect` in edge-cases. To avoid impact, memoize passed in `mapSelectToProps` callbacks or implement `useSelect` directly with dependencies. +- `withDispatch` internally uses a new `useDispatchWithMap` hook (an internal only api) ([#15896](https://github.com/WordPress/gutenberg/pull/15896)) ## 4.5.0 (2019-05-21) diff --git a/packages/data/README.md b/packages/data/README.md index 45a7cdda6f86b9..82a151ebffb08e 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -486,6 +486,52 @@ _Parameters_ - _plugin_ `Object`: Plugin object. +# **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 +} + +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 +} + +// Rendered somewhere in the application: +// +// Start Sale! +``` + +_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. + # **useRegistry** A custom react hook exposing the registry context for use. @@ -573,53 +619,64 @@ _Returns_ # **withDispatch** -Higher-order component used to add dispatch props using registered action creators. +Higher-order component used to add dispatch props using registered action +creators. _Usage_ ```jsx function Button( { onClick, children } ) { - return ; + return ; } const { withDispatch } = wp.data; const SaleButton = withDispatch( ( dispatch, ownProps ) => { - const { startSale } = dispatch( 'my-shop' ); - const { discountPercent } = ownProps; - - return { - onClick() { - startSale( discountPercent ); - }, - }; + const { startSale } = dispatch( 'my-shop' ); + const { discountPercent } = ownProps; + + return { + onClick() { + startSale( discountPercent ); + }, + }; } )( Button ); // Rendered in the application: // -// Start Sale! +// Start Sale! ``` -In the majority of cases, it will be sufficient to use only two first params passed to `mapDispatchToProps` as illustrated in the previous example. However, there might be some very advanced use cases where using the `registry` object might be used as a tool to optimize the performance of your component. Using `select` function from the registry might be useful when you need to fetch some dynamic data from the store at the time when the event is fired, but at the same time, you never use it to render your component. In such scenario, you can avoid using the `withSelect` higher order component to compute such prop, which might lead to unnecessary re-renders of your component caused by its frequent value change. Keep in mind, that `mapDispatchToProps` must return an object with functions only. +In the majority of cases, it will be sufficient to use only two first params +passed to `mapDispatchToProps` as illustrated in the previous example. +However, there might be some very advanced use cases where using the +`registry` object might be used as a tool to optimize the performance of +your component. Using `select` function from the registry might be useful +when you need to fetch some dynamic data from the store at the time when the +event is fired, but at the same time, you never use it to render your +component. In such scenario, you can avoid using the `withSelect` higher +order component to compute such prop, which might lead to unnecessary +re-renders of your component caused by its frequent value change. +Keep in mind, that `mapDispatchToProps` must return an object with functions +only. ```jsx function Button( { onClick, children } ) { - return ; + return ; } const { withDispatch } = wp.data; const SaleButton = withDispatch( ( dispatch, ownProps, { select } ) => { - // Stock number changes frequently. - const { getStockNumber } = select( 'my-shop' ); - const { startSale } = dispatch( 'my-shop' ); - - return { - onClick() { - const dicountPercent = getStockNumber() > 50 ? 10 : 20; - startSale( discountPercent ); - }, - }; + // Stock number changes frequently. + const { getStockNumber } = select( 'my-shop' ); + const { startSale } = dispatch( 'my-shop' ); + return { + onClick() { + const discountPercent = getStockNumber() > 50 ? 10 : 20; + startSale( discountPercent ); + }, + }; } )( Button ); // Rendered in the application: @@ -627,11 +684,9 @@ const SaleButton = withDispatch( ( dispatch, ownProps, { select } ) => { // Start Sale! ``` -_Note:_ It is important that the `mapDispatchToProps` function always returns an object with the same keys. For example, it should not contain conditions under which a different value would be returned. - _Parameters_ -- _mapDispatchToProps_ `Object`: Object of prop names where value is a dispatch-bound action creator, or a function to be called with the component's props and returning an action creator. +- _mapDispatchToProps_ `Function`: A function of returning an object of prop names where value is a dispatch-bound action creator, or a function to be called with the component's props and returning an action creator. _Returns_ diff --git a/packages/data/src/components/use-dispatch/index.js b/packages/data/src/components/use-dispatch/index.js new file mode 100644 index 00000000000000..f4f4fed37d38ca --- /dev/null +++ b/packages/data/src/components/use-dispatch/index.js @@ -0,0 +1,2 @@ +export { default as useDispatch } from './use-dispatch'; +export { default as useDispatchWithMap } from './use-dispatch-with-map'; diff --git a/packages/data/src/components/use-dispatch/test/use-dispatch.js b/packages/data/src/components/use-dispatch/test/use-dispatch.js new file mode 100644 index 00000000000000..8770d04144b06d --- /dev/null +++ b/packages/data/src/components/use-dispatch/test/use-dispatch.js @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import TestRenderer, { act } from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import useDispatch from '../use-dispatch'; +import { createRegistry } from '../../../registry'; +import { RegistryProvider } from '../../registry-provider'; + +describe( 'useDispatch', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + } ); + + it( 'returns dispatch function from store with no store name provided', () => { + registry.registerStore( 'demoStore', { + reducer: ( state ) => state, + actions: { + foo: () => 'bar', + }, + } ); + const TestComponent = () => { + return
; + }; + const Component = () => { + const dispatch = useDispatch(); + return ; + }; + + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); + + const testInstance = testRenderer.root; + + expect( testInstance.findByType( TestComponent ).props.dispatch ) + .toBe( registry.dispatch ); + } ); + it( 'returns expected action creators from store for given storeName', () => { + const noop = () => ( { type: '__INERT__' } ); + const testAction = jest.fn().mockImplementation( noop ); + registry.registerStore( 'demoStore', { + reducer: ( state ) => state, + actions: { + foo: testAction, + }, + } ); + const TestComponent = () => { + const { foo } = useDispatch( 'demoStore' ); + return + * } + * + * 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 + * } + * + * // Rendered somewhere in the application: + * // + * // Start Sale! + * ``` + * @return {Function} A custom react hook. + */ +const useDispatch = ( storeName ) => { + const { dispatch } = useRegistry(); + return storeName === void 0 ? dispatch : dispatch( storeName ); +}; + +export default useDispatch; diff --git a/packages/data/src/components/with-dispatch/index.js b/packages/data/src/components/with-dispatch/index.js index 1da0aa95f3de7a..117edd1f598293 100644 --- a/packages/data/src/components/with-dispatch/index.js +++ b/packages/data/src/components/with-dispatch/index.js @@ -1,138 +1,97 @@ -/** - * External dependencies - */ -import { mapValues } from 'lodash'; - /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; import { 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. + * Higher-order component used to add dispatch props using registered action + * creators. * - * @param {Object} mapDispatchToProps Object of prop names where value is a - * dispatch-bound action creator, or a - * function to be called with the - * component's props and returning an - * action creator. + * @param {Function} mapDispatchToProps A function of returning an object of + * prop names where value is a + * dispatch-bound action creator, or a + * function to be called with the + * component's props and returning an + * action creator. * * @example * ```jsx * function Button( { onClick, children } ) { - * return ; + * return ; * } * * const { withDispatch } = wp.data; * * const SaleButton = withDispatch( ( dispatch, ownProps ) => { - * const { startSale } = dispatch( 'my-shop' ); - * const { discountPercent } = ownProps; + * const { startSale } = dispatch( 'my-shop' ); + * const { discountPercent } = ownProps; * - * return { - * onClick() { - * startSale( discountPercent ); - * }, - * }; + * return { + * onClick() { + * startSale( discountPercent ); + * }, + * }; * } )( Button ); * * // Rendered in the application: * // - * // Start Sale! + * // Start Sale! * ``` * * @example - * In the majority of cases, it will be sufficient to use only two first params passed to `mapDispatchToProps` as illustrated in the previous example. However, there might be some very advanced use cases where using the `registry` object might be used as a tool to optimize the performance of your component. Using `select` function from the registry might be useful when you need to fetch some dynamic data from the store at the time when the event is fired, but at the same time, you never use it to render your component. In such scenario, you can avoid using the `withSelect` higher order component to compute such prop, which might lead to unnecessary re-renders of your component caused by its frequent value change. Keep in mind, that `mapDispatchToProps` must return an object with functions only. + * In the majority of cases, it will be sufficient to use only two first params + * passed to `mapDispatchToProps` as illustrated in the previous example. + * However, there might be some very advanced use cases where using the + * `registry` object might be used as a tool to optimize the performance of + * your component. Using `select` function from the registry might be useful + * when you need to fetch some dynamic data from the store at the time when the + * event is fired, but at the same time, you never use it to render your + * component. In such scenario, you can avoid using the `withSelect` higher + * order component to compute such prop, which might lead to unnecessary + * re-renders of your component caused by its frequent value change. + * Keep in mind, that `mapDispatchToProps` must return an object with functions + * only. * * ```jsx * function Button( { onClick, children } ) { - * return ; + * return ; * } * * const { withDispatch } = wp.data; * * const SaleButton = withDispatch( ( dispatch, ownProps, { select } ) => { - * // Stock number changes frequently. - * const { getStockNumber } = select( 'my-shop' ); - * const { startSale } = dispatch( 'my-shop' ); - * - * return { - * onClick() { - * const dicountPercent = getStockNumber() > 50 ? 10 : 20; - * startSale( discountPercent ); - * }, - * }; + * // Stock number changes frequently. + * const { getStockNumber } = select( 'my-shop' ); + * const { startSale } = dispatch( 'my-shop' ); + * return { + * onClick() { + * const discountPercent = getStockNumber() > 50 ? 10 : 20; + * startSale( discountPercent ); + * }, + * }; * } )( Button ); * * // Rendered in the application: * // * // Start Sale! * ``` - * _Note:_ It is important that the `mapDispatchToProps` function always returns an object with the same keys. For example, it should not contain conditions under which a different value would be returned. * * @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 ; - } - } - - return ( ownProps ) => ( - - { ( registry ) => ( - - ) } - + ( WrappedComponent ) => ( ownProps ) => { + const mapDispatch = ( dispatch, registry ) => mapDispatchToProps( + dispatch, + ownProps, + registry ); + const dispatchProps = useDispatchWithMap( mapDispatch, [] ); + return ; }, 'withDispatch' ); diff --git a/packages/data/src/components/with-dispatch/test/index.js b/packages/data/src/components/with-dispatch/test/index.js index 4bde9b30810ac1..66fba4028e84f8 100644 --- a/packages/data/src/components/with-dispatch/test/index.js +++ b/packages/data/src/components/with-dispatch/test/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import TestRenderer from 'react-test-renderer'; +import TestRenderer, { act } from 'react-test-renderer'; /** * Internal dependencies @@ -45,27 +45,34 @@ describe( 'withDispatch', () => { }; } )( ( props ) =>