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 all 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
2 changes: 2 additions & 0 deletions packages/data/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
107 changes: 81 additions & 26 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 Expand Up @@ -573,65 +619,74 @@ _Returns_

<a name="withDispatch" href="#withDispatch">#</a> **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 <button type="button" onClick={ onClick }>{ children }</button>;
return <button type="button" onClick={ onClick }>{ children }</button>;
}

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:
//
// <SaleButton discountPercent="20">Start Sale!</SaleButton>
// <SaleButton discountPercent="20">Start Sale!</SaleButton>
```

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 <button type="button" onClick={ onClick }>{ children }</button>;
return <button type="button" onClick={ onClick }>{ children }</button>;
}

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:
//
// <SaleButton>Start Sale!</SaleButton>
```

_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_

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';
137 changes: 137 additions & 0 deletions packages/data/src/components/use-dispatch/test/use-dispatch.js
Original file line number Diff line number Diff line change
@@ -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 <div></div>;
};
const Component = () => {
const dispatch = useDispatch();
return <TestComponent dispatch={ dispatch } />;
};

let testRenderer;
act( () => {
testRenderer = TestRenderer.create(
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
} );

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 <button onClick={ foo } />;
};

let testRenderer;

act( () => {
testRenderer = TestRenderer.create(
<RegistryProvider value={ registry } >
<TestComponent />
</RegistryProvider>
);
} );

const testInstance = testRenderer.root;

act( () => {
testInstance.findByType( 'button' ).props.onClick();
} );

expect( testAction ).toHaveBeenCalledTimes( 1 );
} );
it( 'returns dispatch from correct registry if registries change', () => {
const reducer = ( state ) => state;
const noop = () => ( { type: '__INERT__' } );
const firstRegistryAction = jest.fn().mockImplementation( noop );
const secondRegistryAction = jest.fn().mockImplementation( noop );

const firstRegistry = registry;
firstRegistry.registerStore( 'demo', {
reducer,
actions: {
noop: firstRegistryAction,
},
} );

const TestComponent = () => {
const dispatch = useDispatch();
return <button onClick={ () => dispatch( 'demo' ).noop() } />;
};

let testRenderer;
act( () => {
testRenderer = TestRenderer.create(
<RegistryProvider value={ firstRegistry }>
<TestComponent />
</RegistryProvider>
);
} );
const testInstance = testRenderer.root;

act( () => {
testInstance.findByType( 'button' ).props.onClick();
} );

expect( firstRegistryAction ).toHaveBeenCalledTimes( 1 );
expect( secondRegistryAction ).toHaveBeenCalledTimes( 0 );

const secondRegistry = createRegistry();
secondRegistry.registerStore( 'demo', {
reducer,
actions: {
noop: secondRegistryAction,
},
} );

act( () => {
testRenderer.update(
<RegistryProvider value={ secondRegistry }>
<TestComponent />
</RegistryProvider>
);
} );
act( () => {
testInstance.findByType( 'button' ).props.onClick();
} );
expect( firstRegistryAction ).toHaveBeenCalledTimes( 1 );
expect( secondRegistryAction ).toHaveBeenCalledTimes( 1 );
} );
} );
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;