diff --git a/package-lock.json b/package-lock.json
index 65f0fad4ccfdcd..ef2583f4a19915 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16388,6 +16388,16 @@
}
}
},
+ "@testing-library/react-hooks": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz",
+ "integrity": "sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.5.4",
+ "@types/testing-library__react-hooks": "^3.4.0"
+ }
+ },
"@types/anymatch": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz",
@@ -16791,6 +16801,15 @@
"@types/react": "*"
}
},
+ "@types/react-test-renderer": {
+ "version": "16.9.3",
+ "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.3.tgz",
+ "integrity": "sha512-wJ7IlN5NI82XMLOyHSa+cNN4Z0I+8/YaLl04uDgcZ+W+ExWCmCiVTLT/7fRNqzy4OhStZcUwIqLNF7q+AdW43Q==",
+ "dev": true,
+ "requires": {
+ "@types/react": "*"
+ }
+ },
"@types/react-textarea-autosize": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@types/react-textarea-autosize/-/react-textarea-autosize-4.3.5.tgz",
@@ -16969,6 +16988,15 @@
}
}
},
+ "@types/testing-library__react-hooks": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz",
+ "integrity": "sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==",
+ "dev": true,
+ "requires": {
+ "@types/react-test-renderer": "*"
+ }
+ },
"@types/tinycolor2": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.2.tgz",
diff --git a/package.json b/package.json
index 4e4e8e485514e9..ff3fda49a942e5 100644
--- a/package.json
+++ b/package.json
@@ -96,6 +96,7 @@
"@storybook/addon-viewport": "5.3.2",
"@storybook/react": "6.0.21",
"@testing-library/react": "10.0.2",
+ "@testing-library/react-hooks": "3.4.2",
"@types/classnames": "2.2.10",
"@types/eslint": "6.8.0",
"@types/estree": "0.0.44",
diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md
index 38a66378fb1d6d..310767757d8794 100644
--- a/packages/data/CHANGELOG.md
+++ b/packages/data/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### New Feature
+
+- Expose `useStoreSelectors` hook for usage in functional components. ([#26692](https://github.com/WordPress/gutenberg/pull/26692))
+
## 4.6.0 (2019-06-12)
### New Feature
diff --git a/packages/data/README.md b/packages/data/README.md
index 8574f850247d41..1c5d9a92b61d40 100644
--- a/packages/data/README.md
+++ b/packages/data/README.md
@@ -712,6 +712,48 @@ _Returns_
- `Function`: A custom react hook.
+# **useStoreSelectors**
+
+Custom react hook for retrieving selectors from registered stores
+
+_Usage_
+
+```js
+const { useStoreSelectors } = wp.data;
+
+function HammerPriceDisplay( { currency } ) {
+ const price = useStoreSelectors(
+ 'my-shop',
+ ( { getPrice } ) => getPrice( 'hammer', currency ),
+ [ currency ]
+ );
+ return new Intl.NumberFormat( 'en-US', {
+ style: 'currency',
+ currency,
+ } ).format( price );
+}
+
+// Rendered in the application:
+//
+```
+
+In the above example, when `HammerPriceDisplay` is rendered into an
+application, the price will be retrieved from the store state using the
+`mapSelectors` callback on `useStoreSelectors`. If the currency prop changes then
+any price in the state for that currency is retrieved. If the currency prop
+doesn't change and other props are passed in that do change, the price will
+not change because the dependency is just the currency.
+
+_Parameters_
+
+- _storeKey_ `string`: Store to return selectors from.
+- _mapSelectors_ `Function`: Function called on every state change. The returned value is exposed to the component implementing this hook. The function receives the object with all the store selectors as its only argument.
+- _deps_ `Array`: If provided, this memoizes the mapSelect so the same `mapSelect` is invoked on every state change unless the dependencies change.
+
+_Returns_
+
+- `Function`: A custom react hook.
+
# **withDispatch**
Higher-order component used to add dispatch props using registered action
diff --git a/packages/data/src/components/use-store-selectors/index.js b/packages/data/src/components/use-store-selectors/index.js
new file mode 100644
index 00000000000000..ffc5ed007b3af9
--- /dev/null
+++ b/packages/data/src/components/use-store-selectors/index.js
@@ -0,0 +1,50 @@
+/**
+ * Internal dependencies
+ */
+import useSelect from '../use-select';
+
+/**
+ * Custom react hook for retrieving selectors from registered stores
+ *
+ * @param {string} storeKey Store to return selectors from.
+ * @param {Function} mapSelectors Function called on every state change. The
+ * returned value is exposed to the component
+ * implementing this hook. The function receives
+ * the object with all the store selectors as
+ * its only argument.
+ * @param {Array} deps If provided, this memoizes the mapSelect so the
+ * same `mapSelect` is invoked on every state
+ * change unless the dependencies change.
+ *
+ * @example
+ * ```js
+ * const { useStoreSelectors } = wp.data;
+ *
+ * function HammerPriceDisplay( { currency } ) {
+ * const price = useStoreSelectors(
+ * 'my-shop',
+ * ( { getPrice } ) => getPrice( 'hammer', currency ),
+ * [ currency ]
+ * );
+ * return new Intl.NumberFormat( 'en-US', {
+ * style: 'currency',
+ * currency,
+ * } ).format( price );
+ * }
+ *
+ * // Rendered in the application:
+ * //
+ * ```
+ *
+ * In the above example, when `HammerPriceDisplay` is rendered into an
+ * application, the price will be retrieved from the store state using the
+ * `mapSelectors` callback on `useStoreSelectors`. If the currency prop changes then
+ * any price in the state for that currency is retrieved. If the currency prop
+ * doesn't change and other props are passed in that do change, the price will
+ * not change because the dependency is just the currency.
+ *
+ * @return {Function} A custom react hook.
+ */
+export default function useStoreSelectors( storeKey, mapSelectors, deps = [] ) {
+ return useSelect( ( select ) => mapSelectors( select( storeKey ) ), deps );
+}
diff --git a/packages/data/src/components/use-store-selectors/test/index.js b/packages/data/src/components/use-store-selectors/test/index.js
new file mode 100644
index 00000000000000..e1d07202d497ad
--- /dev/null
+++ b/packages/data/src/components/use-store-selectors/test/index.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react-hooks';
+
+/**
+ * WordPress dependencies
+ */
+import { RegistryProvider } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { createRegistry } from '../../../registry';
+import useStoreSelectors from '../index';
+
+describe( 'useStoreSelectors', () => {
+ let registry;
+ beforeEach( () => {
+ registry = createRegistry();
+ } );
+
+ it( 'wraps useSelect', () => {
+ registry.registerStore( 'testStore', {
+ reducer: () => ( { foo: 'bar' } ),
+ selectors: {
+ testSelector: ( state, key ) => state[ key ],
+ },
+ } );
+
+ const wrapper = ( { children } ) => (
+ { children }
+ );
+
+ const useHook = () =>
+ useStoreSelectors( 'testStore', ( { testSelector } ) =>
+ testSelector( 'foo' )
+ );
+
+ const { result } = renderHook( useHook, { wrapper } );
+
+ // ensure expected state was rendered
+ expect( result.current ).toEqual( 'bar' );
+ } );
+} );
diff --git a/packages/data/src/index.js b/packages/data/src/index.js
index 911b6e02983798..075cfbbbd5b74a 100644
--- a/packages/data/src/index.js
+++ b/packages/data/src/index.js
@@ -18,6 +18,7 @@ export {
useRegistry,
} from './components/registry-provider';
export { default as useSelect } from './components/use-select';
+export { default as useStoreSelectors } from './components/use-store-selectors';
export { useDispatch } from './components/use-dispatch';
export { AsyncModeProvider } from './components/async-mode-provider';
export { createRegistry } from './registry';