diff --git a/data/README.md b/data/README.md
index 32522d178e9e6..134483cd61ca5 100644
--- a/data/README.md
+++ b/data/README.md
@@ -39,6 +39,25 @@ Let's say the state of our plugin (registered with the key `myPlugin`) has the f
wp.data.registerSelectors( 'myPlugin', { getTitle: ( state ) => state.title } );
```
+### `wp.data.registerActions( reducerKey: string, newActions: object )`
+
+If your module or plugin needs to expose its actions to other modules and plugins, you'll have to register action creators.
+
+An action creator is a function that takes arguments and returns an action object dispatch to the registered reducer to update the state.
+
+#### Example:
+
+```js
+wp.data.registerActions( 'myPlugin', {
+ setTitle( newTitle ) {
+ return {
+ type: 'SET_TITLE',
+ title: newTitle,
+ };
+ },
+} );
+```
+
### `wp.data.select( key: string )`
This function allows calling any registered selector. Given a module's key, this function returns an object of all selector functions registered for the module.
@@ -49,18 +68,14 @@ This function allows calling any registered selector. Given a module's key, this
wp.data.select( 'myPlugin' ).getTitle(); // Returns "My post title"
```
-### `wp.data.query( mapSelectorsToProps: function )( WrappedComponent: Component )`
+### `wp.data.dispatch( key: string )`
-If you use a React or WordPress Element, a Higher Order Component is made available to inject data into your components like so:
+This function allows calling any registered action. Given a module's key, this function returns an object of all action creators functions registered for the module.
-```js
-const Component = ( { title } ) =>
{ title }
;
+#### Example:
-wp.data.query( select => {
- return {
- title: select( 'myPlugin' ).getTitle(),
- };
-} )( Component );
+```js
+wp.data.dispatch( 'myPlugin' ).setTitle( 'new Title' ); // Dispatches the setTitle action to the reducer
```
### `wp.data.subscribe( listener: function )`
@@ -80,3 +95,38 @@ const unsubscribe = wp.data.subscribe( () => {
// Unsubcribe.
unsubscribe();
```
+
+### `wp.data.withSelect( mapStateToProps: Object|Function )( WrappedComponent: Component )`
+
+To inject state-derived props into a WordPress Element Component, use the `withSelect` higher-order component:
+
+```jsx
+const Component = ( { title } ) =>
{ title }
;
+
+const EnhancedComponent = wp.data.withSelect( ( select ) => {
+ return {
+ title: select( 'myPlugin' ).getTitle,
+ };
+} )( Component );
+```
+
+### `wp.data.withDispatch( propsToDispatchers: Object )( WrappedComponent: Component )`
+
+To manipulate store data, you can pass dispatching actions into your component as props using the `withDispatch` higher-order component:
+
+```jsx
+const Component = ( { title, updateTitle } ) => ;
+
+const EnhancedComponent = wp.element.compose( [
+ wp.data.withSelect( ( select ) => {
+ return {
+ title: select( 'myPlugin' ).getTitle(),
+ };
+ } ),
+ wp.data.withDispatch( ( dispatch ) => {
+ return {
+ updateTitle: dispatch( 'myPlugin' ).setTitle,
+ };
+ } ),
+] )( Component );
+```
diff --git a/data/index.js b/data/index.js
index 0d80e47cb63b6..93fa8fa80c09a 100644
--- a/data/index.js
+++ b/data/index.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { connect } from 'react-redux';
+import isEqualShallow from 'is-equal-shallow';
import { createStore } from 'redux';
import { flowRight, without, mapValues } from 'lodash';
@@ -9,6 +9,7 @@ import { flowRight, without, mapValues } from 'lodash';
* WordPress dependencies
*/
import { deprecated } from '@wordpress/utils';
+import { Component, getWrapperDisplayName } from '@wordpress/element';
/**
* Internal dependencies
@@ -20,6 +21,7 @@ export { loadAndPersist, withRehydratation } from './persist';
*/
const stores = {};
const selectors = {};
+const actions = {};
let listeners = [];
/**
@@ -29,21 +31,6 @@ export function globalListener() {
listeners.forEach( listener => listener() );
}
-/**
- * Subscribe to changes to any data.
- *
- * @param {Function} listener Listener function.
- *
- * @return {Function} Unsubscribe function.
- */
-export const subscribe = ( listener ) => {
- listeners.push( listener );
-
- return () => {
- listeners = without( listeners, listener );
- };
-};
-
/**
* Registers a new sub-reducer to the global state and returns a Redux-like store object.
*
@@ -79,6 +66,34 @@ export function registerSelectors( reducerKey, newSelectors ) {
selectors[ reducerKey ] = mapValues( newSelectors, createStateSelector );
}
+/**
+ * Registers actions for external usage.
+ *
+ * @param {string} reducerKey Part of the state shape to register the
+ * selectors for.
+ * @param {Object} newActions Actions to register.
+ */
+export function registerActions( reducerKey, newActions ) {
+ const store = stores[ reducerKey ];
+ const createBoundAction = ( action ) => ( ...args ) => store.dispatch( action( ...args ) );
+ actions[ reducerKey ] = mapValues( newActions, createBoundAction );
+}
+
+/**
+ * Subscribe to changes to any data.
+ *
+ * @param {Function} listener Listener function.
+ *
+ * @return {Function} Unsubscribe function.
+ */
+export const subscribe = ( listener ) => {
+ listeners.push( listener );
+
+ return () => {
+ listeners = without( listeners, listener );
+ };
+};
+
/**
* Calls a selector given the current state and extra arguments.
*
@@ -102,33 +117,142 @@ export function select( reducerKey ) {
}
/**
- * Higher Order Component used to inject data using the registered selectors.
+ * Returns the available actions for a part of the state.
*
- * @param {Function} mapSelectorsToProps Gets called with the selectors object
- * to determine the data for the
- * component.
+ * @param {string} reducerKey Part of the state shape to dispatch the
+ * action for.
*
- * @return {Function} Renders the wrapped component and passes it data.
+ * @return {*} The action's returned value.
*/
-export const query = ( mapSelectorsToProps ) => ( WrappedComponent ) => {
- const store = {
- getState() {
- return mapValues( stores, subStore => subStore.getState() );
- },
- subscribe,
- dispatch() {
- // eslint-disable-next-line no-console
- console.warn( 'Dispatch is not supported.' );
- },
- };
- const connectWithStore = ( ...args ) => {
- const ConnectedWrappedComponent = connect( ...args )( WrappedComponent );
- return ( props ) => {
- return ;
- };
- };
+export function dispatch( reducerKey ) {
+ return actions[ reducerKey ];
+}
+
+/**
+ * Higher-order component used to inject state-derived props using registered
+ * selectors.
+ *
+ * @param {Function} mapStateToProps Function called on every state change,
+ * expected to return object of props to
+ * merge with the component's own props.
+ *
+ * @return {Component} Enhanced component with merged state data props.
+ */
+export const withSelect = ( mapStateToProps ) => ( WrappedComponent ) => {
+ class ComponentWithSelect extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.runSelection = this.runSelection.bind( this );
+
+ this.state = {};
+ }
+
+ componentWillMount() {
+ this.subscribe();
+
+ // Populate initial state.
+ this.runSelection();
+ }
+
+ componentWillReceiveProps( nextProps ) {
+ if ( ! isEqualShallow( nextProps, this.props ) ) {
+ this.runSelection( nextProps );
+ }
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ subscribe() {
+ this.unsubscribe = subscribe( this.runSelection );
+ }
+
+ runSelection( props = this.props ) {
+ const newState = mapStateToProps( select, props );
+ if ( ! isEqualShallow( newState, this.state ) ) {
+ this.setState( newState );
+ }
+ }
+
+ render() {
+ return ;
+ }
+ }
+
+ ComponentWithSelect.displayName = getWrapperDisplayName( WrappedComponent, 'select' );
+
+ return ComponentWithSelect;
+};
+
+/**
+ * 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 with the
+ * component's props and returning an
+ * action creator.
+ *
+ * @return {Component} Enhanced component with merged dispatcher props.
+ */
+export const withDispatch = ( mapDispatchToProps ) => ( WrappedComponent ) => {
+ class ComponentWithDispatch extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.proxyProps = {};
+ }
+
+ componentWillMount() {
+ this.setProxyProps( this.props );
+ }
+
+ componentWillUpdate( nextProps ) {
+ this.setProxyProps( nextProps );
+ }
+
+ proxyDispatch( propName, ...args ) {
+ // Original dispatcher is a pre-bound (dispatching) action creator.
+ mapDispatchToProps( dispatch, this.props )[ propName ]( ...args );
+ }
+
+ setProxyProps( props ) {
+ // Assign as instance property so that in reconciling subsequent
+ // renders, the assigned prop values are referentially equal.
+ const propsToDispatchers = mapDispatchToProps( dispatch, props );
+ this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => {
+ // 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 ;
+ }
+ }
+
+ ComponentWithDispatch.displayName = getWrapperDisplayName( WrappedComponent, 'dispatch' );
+
+ return ComponentWithDispatch;
+};
+
+export const query = ( mapSelectToProps ) => {
+ deprecated( 'wp.data.query', {
+ version: '2.5',
+ alternative: 'wp.data.withSelect',
+ plugin: 'Gutenberg',
+ } );
- return connectWithStore( ( state, ownProps ) => {
- return mapSelectorsToProps( select, ownProps );
+ return withSelect( ( props ) => {
+ return mapSelectToProps( select, props );
} );
};
diff --git a/data/test/__snapshots__/index.js.snap b/data/test/__snapshots__/index.js.snap
deleted file mode 100644
index 46758a91bd19a..0000000000000
--- a/data/test/__snapshots__/index.js.snap
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`query passes the relevant data to the component 1`] = `
-
- reactState
-
-`;
diff --git a/data/test/index.js b/data/test/index.js
index dfafb0e286f05..8b63e41f4e716 100644
--- a/data/test/index.js
+++ b/data/test/index.js
@@ -1,12 +1,26 @@
/**
* External dependencies
*/
-import { render } from 'enzyme';
+import { mount } from 'enzyme';
+
+/**
+ * WordPress dependencies
+ */
+import { compose } from '@wordpress/element';
/**
* Internal dependencies
*/
-import { registerReducer, registerSelectors, select, query, subscribe } from '../';
+import {
+ registerReducer,
+ registerSelectors,
+ registerActions,
+ dispatch,
+ select,
+ withSelect,
+ withDispatch,
+ subscribe,
+} from '../';
describe( 'store', () => {
it( 'Should append reducers to the state', () => {
@@ -51,23 +65,146 @@ describe( 'select', () => {
} );
} );
-describe( 'query', () => {
+describe( 'withSelect', () => {
it( 'passes the relevant data to the component', () => {
registerReducer( 'reactReducer', () => ( { reactKey: 'reactState' } ) );
registerSelectors( 'reactReducer', {
reactSelector: ( state, key ) => state[ key ],
} );
- const Component = query( ( selectFunc, ownProps ) => {
+
+ // In normal circumstances, the fact that we have to add an arbitrary
+ // prefix to the variable name would be concerning, and perhaps an
+ // argument that we ought to expect developer to use select from the
+ // wp.data export. But in-fact, this serves as a good deterrent for
+ // including both `withSelect` and `select` in the same scope, which
+ // shouldn't occur for a typical component, and if it did might wrongly
+ // encourage the developer to use `select` within the component itself.
+ const Component = withSelect( ( _select, ownProps ) => ( {
+ data: _select( 'reactReducer' ).reactSelector( ownProps.keyName ),
+ } ) )( ( props ) =>