Repository: reduxjs/react-redux
Pull request #1248 (2019-04-19 13:51:56)

add react hooks for accessing redux store state and dispatching redux actions
As discussed in the [hooks API design issue](https://github.com/reduxjs/react-redux/issues/1179), here is an implementation that could be used as an alpha.

There are a couple of open points:

- are tests for HMR required? (the corresponding tests in `connect.spec.js` are skipped)
- I added inline documentation to the hooks, but other code doesn't have this (instead, the docu is in the [types](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react-redux/index.d.ts)); not sure what you want to do here
- I haven't updated the docs yet to mention hooks
- naming of hooks could change depending on the [API design issue](https://github.com/reduxjs/react-redux/issues/1179)

netlify[bot]: Deploy preview for *react-redux-docs* ready!

Built with commit fdadc6e4234460bb53dd9a04cfbb0aa951d77255

https://deploy-preview-1248--react-redux-docs.netlify.com
markerikson: Awesome. Initial thoughts:

- Yeah, I'd still like to have the HMR tests updated at some point, but that's @theKashey 's area of expertise.  I've pinged him a couple times on that and haven't gotten anything.
- Totally fine with having some inline documentation for now.
- We can hold off on actually updating the docs for the moment while this is still in alpha .  I know @wgao19 set up versioned docs, but I'm not clear yet on how to handle the "current" vs "next" docs content.
- Per the naming: I still rather like `useSelector`, but on the flip side, the poll at https://twitter.com/acemarke/status/1118952682025377792 says a lot more people like `useReduxState()`.  Doesn't mean we _have_ to do that, but it's a data point.
- I did actually kinda want `useRedux(selector, actions)` in there because of the strong parallel with `connect()`, to make it as easy as possible for folks to migrate if they want.  I know it's also basically a 2-liner, so anyone could write it if they so choose, but it seems reasonable to include it.
- I have slightly mixed feelings about exporting `useReduxContext()`.  We've danced on the line of whether or not that sort of thing is actually part of our public API.  On the one hand, the "Accessing the Store" docs page explicitly says "this isn't part of our public API".  On the other hand, we _do_ export `ReactReduxContext`, and if that's exported, than obviously anyone can just do `useContext(ReactReduxContext)`.

Also, not sure whether it's better to merge straight into master and publish the alpha from there, or set up a `hooks-alpha` branch instead and iterate on that.  May be dependent on the versioned docs thing.  @timdorr , @wgao19 , thoughts?
hedgerh: @markerikson 

> `useRedux(selector, actions)`

I like the idea of only needing to import one hook.  Only trade-off I can think of is possible confusion or needless bikeshedding by giving people two very similar ways to do something.

> `useReduxState`

I didn't like it at first, but I actually like that it's more explicit about what the hook is doing.  This may be especially helpful for beginners.  I was a fan of `useSelect`, myself.
wgao19: Hi @markerikson,

- To update docs for "next" version, make changes to the docs under project root's [docs/](https://github.com/reduxjs/react-redux/tree/master/docs) directory.
- To update docs for "current" version, or any previous stable versions, make changes to [website/versioned_docs/](https://github.com/reduxjs/react-redux/tree/master/website/versioned_docs) in their respective subdirectories.
- When this alpha version becomes stable release, run Docusaurus' version command to copy root docs/ into versioned_docs, and further changes should happen there from thereon.

I've checked Docusaurus' docs regarding versioned docs on other branches, it seems they cannot associate a version to a specific branch. The changes to docs should happen on `master`.
MrWolfZ: > Per the naming: I still rather like useSelector, but on the flip side, the poll at https://twitter.com/acemarke/status/1118952682025377792 says a lot more people like useReduxState(). Doesn't mean we have to do that, but it's a data point.

In my POC I also had `useReduxState`, so I am definitely in favor of that naming. Just let me know if you want me to change it.

> I did actually kinda want useRedux(selector, actions) in there because of the strong parallel with connect(), to make it as easy as possible for folks to migrate if they want. I know it's also basically a 2-liner, so anyone could write it if they so choose, but it seems reasonable to include it.

I'll add a `useRedux` hook.

> I have slightly mixed feelings about exporting useReduxContext(). We've danced on the line of whether or not that sort of thing is actually part of our public API. On the one hand, the "Accessing the Store" docs page explicitly says "this isn't part of our public API". On the other hand, we do export ReactReduxContext, and if that's exported, than obviously anyone can just do useContext(ReactReduxContext).

Yeah, I had the same thoughts. However, since this hook can easily be written by anyone themselves and in the spirit of keeping the exposed API minimal, I'll remove the export of this hook.

> My one concern here is swallowing errors when the component doesn't re-render.
> 
> Perhaps it's worth considering that "window of opportunity" approach from easy-peasy again?

The window of opportunity (as implemented in easy-peasy) would also only throw the error if the component gets re-rendered. I still argue that for transient errors or for unmounted components it simply does not matter. The selector is a pure function without side-effects. Therefore, why would you care about an error that never has any observable effect (quantum scientists would argue that any component is always in an error state as long as you don't render it ;) )?

> The other downside is potentially losing some of the stack trace pointing back to the dispatched action.

Yeah, I hadn't thought about that. I have set up a [sandbox](https://codesandbox.io/s/lp323xn0ol) that shows how it would currently look like. In the example you don't get the dispatch in the stack trace, only the click event that triggered the dispatch. I have prototyped a solution for this [here](https://github.com/MrWolfZ/react-redux-hooks-poc/blob/349db58a99ea929ecfdcac2bafcbb9d59f25a978/src/useReduxState.ts#L45) and you can see it in action in this [sandbox](https://codesandbox.io/s/8knv6qo4w9). Basically, if in a render following an error in the subscription callback another error occurs, we put the merge the errors. This way you get both stack traces. What do you think?
markerikson: Well, [the latest poll between just `useSelector` and `useReduxState`](https://twitter.com/acemarke/status/1119640788172980224) started off at about 70% for `useReduxState`, but has since narrowed down to a tossup - 51/49.  

So, I'm inclined to stick with `useSelector` for now.
theKashey: > Yeah, I'd still like to have the HMR tests updated at some point, but that's @theKashey 's area of expertise. I've pinged him a couple times on that and haven't gotten anything.

There would be some problems with HMR, and probably not only with it.
- `useActions` has no dependency on `actions`, and could not have, as long as it's a unique object every time. 
- in the same time `useSelector` is "free" from this effect, as long as the "current" selector is stored in ref.

There is a simple way to mitigate `useAction` - use `JSON.stringify(actions)` as a key for `useMemo`(only in dev mode).
Another way to fix the problem - do nothing, as long as it's impossible to manually _fix_ each and every library, so the problem should be fixed from the _other side_ 
markerikson: @MrWolfZ : I think I was worried that there might still be a case where an error is caught, the component doesn't re-render, and the user never sees it, but I think that's handled with the `catch(e) {}  forceRender()` sequence.

Yeah, lemme pull over the error handling changes you made for that POC.
markerikson: Let's put this on an alpha branch for now.  That way if we have to iterate on this any, we can still put out minor builds from master in the meantime.

I know any alpha docs do have to go in master, and that's okay, since it's versioned.
markerikson: Well, green means go, right?

Patch:
diff --git a/package.json b/package.json
index f9c8762..5b6ba86 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
     "build": "npm run build:commonjs && npm run build:es && npm run build:umd && npm run build:umd:min",
     "clean": "rimraf lib dist es coverage",
     "format": "prettier --write \"{src,test}/**/*.{js,ts}\" index.d.ts \"docs/**/*.md\"",
-    "lint": "eslint src test/utils test/components",
+    "lint": "eslint src test/utils test/components test/hooks",
     "prepare": "npm run clean && npm run build",
     "pretest": "npm run lint",
     "test": "jest",
diff --git a/src/alternate-renderers.js b/src/alternate-renderers.js
index adc4430..7bfa04e 100644
--- a/src/alternate-renderers.js
+++ b/src/alternate-renderers.js
@@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced'
 import { ReactReduxContext } from './components/Context'
 import connect from './connect/connect'
 
+import { useActions } from './hooks/useActions'
+import { useDispatch } from './hooks/useDispatch'
+import { useRedux } from './hooks/useRedux'
+import { useSelector } from './hooks/useSelector'
+import { useStore } from './hooks/useStore'
+
 import { getBatch } from './utils/batch'
 
 // For other renderers besides ReactDOM and React Native, use the default noop batch function
 const batch = getBatch()
 
-export { Provider, connectAdvanced, ReactReduxContext, connect, batch }
+export {
+  Provider,
+  connectAdvanced,
+  ReactReduxContext,
+  connect,
+  batch,
+  useActions,
+  useDispatch,
+  useRedux,
+  useSelector,
+  useStore
+}
diff --git a/src/hooks/useActions.js b/src/hooks/useActions.js
new file mode 100644
index 0000000..4c924f5
--- /dev/null
+++ b/src/hooks/useActions.js
@@ -0,0 +1,74 @@
+import { bindActionCreators } from 'redux'
+import invariant from 'invariant'
+import { useDispatch } from './useDispatch'
+import { useMemo } from 'react'
+
+/**
+ * A hook to bind action creators to the redux store's `dispatch` function
+ * similar to how redux's `bindActionCreators` works.
+ * 
+ * Supports passing a single action creator, an array/tuple of action
+ * creators, or an object of action creators.
+ * 
+ * Any arguments passed to the created callbacks are passed through to
+ * the your functions.
+ * 
+ * This hook takes a dependencies array as an optional second argument,
+ * which when passed ensures referential stability of the created callbacks.
+ * 
+ * @param {Function|Function[]|Object.<string, Function>} actions the action creators to bind
+ * @param {any[]} deps (optional) dependencies array to control referential stability
+ * 
+ * @returns {Function|Function[]|Object.<string, Function>} callback(s) bound to store's `dispatch` function
+ *
+ * Usage:
+ *
+```jsx
+import React from 'react'
+import { useActions } from 'react-redux'
+
+const increaseCounter = ({ amount }) => ({
+  type: 'increase-counter',
+  amount,
+})
+
+export const CounterComponent = ({ value }) => {
+  // supports passing an object of action creators
+  const { increaseCounterByOne, increaseCounterByTwo } = useActions({
+    increaseCounterByOne: () => increaseCounter(1),
+    increaseCounterByTwo: () => increaseCounter(2),
+  }, [])
+
+  // supports passing an array/tuple of action creators
+  const [increaseCounterByThree, increaseCounterByFour] = useActions([
+    () => increaseCounter(3),
+    () => increaseCounter(4),
+  ], [])
+
+  // supports passing a single action creator
+  const increaseCounterBy5 = useActions(() => increaseCounter(5), [])
+
+  // passes through any arguments to the callback
+  const increaseCounterByX = useActions(x => increaseCounter(x), [])
+
+  return (
+    <div>
+      <span>{value}</span>
+      <button onClick={increaseCounterByOne}>Increase counter by 1</button>
+    </div>
+  )
+}
+```
+ */
+export function useActions(actions, deps) {
+  invariant(actions, `You must pass actions to useActions`)
+
+  const dispatch = useDispatch()
+  return useMemo(() => {
+    if (Array.isArray(actions)) {
+      return actions.map(a => bindActionCreators(a, dispatch))
+    }
+
+    return bindActionCreators(actions, dispatch)
+  }, deps)
+}
diff --git a/src/hooks/useDispatch.js b/src/hooks/useDispatch.js
new file mode 100644
index 0000000..7331ea3
--- /dev/null
+++ b/src/hooks/useDispatch.js
@@ -0,0 +1,31 @@
+import { useStore } from './useStore'
+
+/**
+ * A hook to access the redux `dispatch` function. Note that in most cases where you
+ * might want to use this hook it is recommended to use `useActions` instead to bind
+ * action creators to the `dispatch` function.
+ * 
+ * @returns {any} redux store's `dispatch` function
+ *
+ * Usage:
+ *
+```jsx
+import React, { useCallback } from 'react'
+import { useReduxDispatch } from 'react-redux'
+
+export const CounterComponent = ({ value }) => {
+  const dispatch = useDispatch()
+  const increaseCounter = useCallback(() => dispatch({ type: 'increase-counter' }), [])
+  return (
+    <div>
+      <span>{value}</span>
+      <button onClick={increaseCounter}>Increase counter</button>
+    </div>
+  )
+}
+```
+ */
+export function useDispatch() {
+  const store = useStore()
+  return store.dispatch
+}
diff --git a/src/hooks/useRedux.js b/src/hooks/useRedux.js
new file mode 100644
index 0000000..58f7f4e
--- /dev/null
+++ b/src/hooks/useRedux.js
@@ -0,0 +1,40 @@
+import { useSelector } from './useSelector'
+import { useActions } from './useActions'
+
+/**
+ * A hook to access the redux store's state and to bind action creators to 
+ * the store's dispatch function. In essence, this hook is a combination of
+ * `useSelector` and `useActions`.
+ * 
+ * @param {Function} selector the selector function
+ * @param {Function|Function[]|Object.<string, Function>} actions the action creators to bind
+ * 
+ * @returns {[any, any]} a tuple of the selected state and the bound action creators
+ *
+ * Usage:
+ *
+```jsx
+import React from 'react'
+import { useRedux } from 'react-redux'
+
+export const CounterComponent = () => {
+  const [counter, { inc1, inc }] = useRedux(state => state.counter, {
+    inc1: () => ({ type: 'inc1' }),
+    inc: amount => ({ type: 'inc', amount }),
+  })
+
+  return (
+    <>
+      <div>
+        {counter}
+      </div>
+      <button onClick={inc1}>Increment by 1</button>
+      <button onClick={() => inc(5)}>Increment by 5</button>
+    </>
+  )
+}
+```
+ */
+export function useRedux(selector, actions) {
+  return [useSelector(selector), useActions(actions)]
+}
diff --git a/src/hooks/useReduxContext.js b/src/hooks/useReduxContext.js
new file mode 100644
index 0000000..e580b0e
--- /dev/null
+++ b/src/hooks/useReduxContext.js
@@ -0,0 +1,32 @@
+import { useContext } from 'react'
+import invariant from 'invariant'
+import { ReactReduxContext } from '../components/Context'
+
+/**
+ * A hook to access the value of the `ReactReduxContext`. This is a low-level
+ * hook that you should usually not need to call directly.
+ * 
+ * @returns {any} the value of the `ReactReduxContext`
+ *
+ * Usage:
+ *
+```jsx
+import React from 'react'
+import { useReduxContext } from 'react-redux'
+
+export const CounterComponent = ({ value }) => {
+  const { store } = useReduxContext()
+  return <div>{store.getState()}</div>
+}
+```
+ */
+export function useReduxContext() {
+  const contextValue = useContext(ReactReduxContext)
+
+  invariant(
+    contextValue,
+    'could not find react-redux context value; please ensure the component is wrapped in a <Provider>'
+  )
+
+  return contextValue
+}
diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js
new file mode 100644
index 0000000..397a93c
--- /dev/null
+++ b/src/hooks/useSelector.js
@@ -0,0 +1,108 @@
+import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react'
+import invariant from 'invariant'
+import { useReduxContext } from './useReduxContext'
+import shallowEqual from '../utils/shallowEqual'
+import Subscription from '../utils/Subscription'
+
+// React currently throws a warning when using useLayoutEffect on the server.
+// To get around it, we can conditionally useEffect on the server (no-op) and
+// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store
+// subscription callback always has the selector from the latest render commit
+// available, otherwise a store update may happen between render and the effect,
+// which may cause missed updates; we also must ensure the store subscription
+// is created synchronously, otherwise a store update may occur before the
+// subscription is created and an inconsistent state may be observed
+const useIsomorphicLayoutEffect =
+  typeof window !== 'undefined' ? useLayoutEffect : useEffect
+
+/**
+ * A hook to access the redux store's state. This hook takes a selector function
+ * as an argument. The selector is called with the store state.
+ * 
+ * @param {Function} selector the selector function
+ * 
+ * @returns {any} the selected state
+ *
+ * Usage:
+ *
+```jsx
+import React from 'react'
+import { useSelector } from 'react-redux'
+
+export const CounterComponent = () => {
+  const counter = useSelector(state => state.counter)
+  return <div>{counter}</div>
+}
+```
+ */
+export function useSelector(selector) {
+  invariant(selector, `You must pass a selector to useSelectors`)
+
+  const { store, subscription: contextSub } = useReduxContext()
+  const [, forceRender] = useReducer(s => s + 1, 0)
+
+  const subscription = useMemo(() => new Subscription(store, contextSub), [
+    store,
+    contextSub
+  ])
+
+  const latestSubscriptionCallbackError = useRef()
+  const latestSelector = useRef(selector)
+
+  let selectedState = undefined
+
+  try {
+    selectedState = latestSelector.current(store.getState())
+  } catch (err) {
+    let errorMessage = `An error occured while selecting the store state: ${
+      err.message
+    }.`
+
+    if (latestSubscriptionCallbackError.current) {
+      errorMessage += `\nThe error may be correlated with this previous error:\n${
+        latestSubscriptionCallbackError.current.stack
+      }\n\nOriginal stack trace:`
+    }
+
+    throw new Error(errorMessage)
+  }
+
+  const latestSelectedState = useRef(selectedState)
+
+  useIsomorphicLayoutEffect(() => {
+    latestSelector.current = selector
+    latestSelectedState.current = selectedState
+    latestSubscriptionCallbackError.current = undefined
+  })
+
+  useIsomorphicLayoutEffect(() => {
+    function checkForUpdates() {
+      try {
+        const newSelectedState = latestSelector.current(store.getState())
+
+        if (shallowEqual(newSelectedState, latestSelectedState.current)) {
+          return
+        }
+
+        latestSelectedState.current = newSelectedState
+      } catch (err) {
+        // we ignore all errors here, since when the component
+        // is re-rendered, the selectors are called again, and
+        // will throw again, if neither props nor store state
+        // changed
+        latestSubscriptionCallbackError.current = err
+      }
+
+      forceRender({})
+    }
+
+    subscription.onStateChange = checkForUpdates
+    subscription.trySubscribe()
+
+    checkForUpdates()
+
+    return () => subscription.tryUnsubscribe()
+  }, [store, subscription])
+
+  return selectedState
+}
diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js
new file mode 100644
index 0000000..ff19882
--- /dev/null
+++ b/src/hooks/useStore.js
@@ -0,0 +1,23 @@
+import { useReduxContext } from './useReduxContext'
+
+/**
+ * A hook to access the redux store.
+ * 
+ * @returns {any} the redux store
+ *
+ * Usage:
+ *
+```jsx
+import React from 'react'
+import { useStore } from 'react-redux'
+
+export const CounterComponent = ({ value }) => {
+  const store = useStore()
+  return <div>{store.getState()}</div>
+}
+```
+ */
+export function useStore() {
+  const { store } = useReduxContext()
+  return store
+}
diff --git a/src/index.js b/src/index.js
index 8d6460f..3431cb6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced'
 import { ReactReduxContext } from './components/Context'
 import connect from './connect/connect'
 
+import { useActions } from './hooks/useActions'
+import { useDispatch } from './hooks/useDispatch'
+import { useRedux } from './hooks/useRedux'
+import { useSelector } from './hooks/useSelector'
+import { useStore } from './hooks/useStore'
+
 import { setBatch } from './utils/batch'
 import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'
 
 setBatch(batch)
 
-export { Provider, connectAdvanced, ReactReduxContext, connect, batch }
+export {
+  Provider,
+  connectAdvanced,
+  ReactReduxContext,
+  connect,
+  batch,
+  useActions,
+  useDispatch,
+  useRedux,
+  useSelector,
+  useStore
+}


Test patch:
diff --git a/test/hooks/useActions.spec.js b/test/hooks/useActions.spec.js
new file mode 100644
index 0000000..e87b809
--- /dev/null
+++ b/test/hooks/useActions.spec.js
@@ -0,0 +1,178 @@
+import React from 'react'
+import { createStore } from 'redux'
+import * as rtl from 'react-testing-library'
+import { Provider as ProviderMock, useActions } from '../../src/index.js'
+
+describe('React', () => {
+  describe('hooks', () => {
+    describe('useActions', () => {
+      let store
+      let dispatchedActions = []
+
+      beforeEach(() => {
+        const reducer = (state = 0, action) => {
+          dispatchedActions.push(action)
+
+          if (action.type === 'inc1') {
+            return state + 1
+          }
+
+          if (action.type === 'inc') {
+            return state + action.amount
+          }
+
+          return state
+        }
+
+        store = createStore(reducer)
+        dispatchedActions = []
+      })
+
+      afterEach(() => rtl.cleanup())
+
+      it('supports a single action creator', () => {
+        const Comp = () => {
+          const inc1 = useActions(() => ({ type: 'inc1' }))
+
+          return (
+            <>
+              <button id="bInc1" onClick={inc1} />
+            </>
+          )
+        }
+
+        const { container } = rtl.render(
+          <ProviderMock store={store}>
+            <Comp />
+          </ProviderMock>
+        )
+
+        const bInc1 = container.querySelector('#bInc1')
+
+        rtl.fireEvent.click(bInc1)
+
+        expect(dispatchedActions).toEqual([{ type: 'inc1' }])
+      })
+
+      it('supports an object of action creators', () => {
+        const Comp = () => {
+          const { inc1, inc2 } = useActions({
+            inc1: () => ({ type: 'inc1' }),
+            inc2: () => ({ type: 'inc', amount: 2 })
+          })
+
+          return (
+            <>
+              <button id="bInc1" onClick={inc1} />
+              <button id="bInc2" onClick={inc2} />
+            </>
+          )
+        }
+
+        const { container } = rtl.render(
+          <ProviderMock store={store}>
+            <Comp />
+          </ProviderMock>
+        )
+
+        const bInc1 = container.querySelector('#bInc1')
+        const bInc2 = container.querySelector('#bInc2')
+
+        rtl.fireEvent.click(bInc1)
+        rtl.fireEvent.click(bInc2)
+
+        expect(dispatchedActions).toEqual([
+          { type: 'inc1' },
+          { type: 'inc', amount: 2 }
+        ])
+      })
+
+      it('supports an array of action creators', () => {
+        const Comp = () => {
+          const [inc1, inc2] = useActions([
+            () => ({ type: 'inc1' }),
+            () => ({ type: 'inc', amount: 2 })
+          ])
+
+          return (
+            <>
+              <button id="bInc1" onClick={inc1} />
+              <button id="bInc2" onClick={inc2} />
+            </>
+          )
+        }
+
+        const { container } = rtl.render(
+          <ProviderMock store={store}>
+            <Comp />
+          </ProviderMock>
+        )
+
+        const bInc1 = container.querySelector('#bInc1')
+        const bInc2 = container.querySelector('#bInc2')
+
+        rtl.fireEvent.click(bInc1)
+        rtl.fireEvent.click(bInc2)
+
+        expect(dispatchedActions).toEqual([
+          { type: 'inc1' },
+          { type: 'inc', amount: 2 }
+        ])
+      })
+
+      it('passes through arguments', () => {
+        const reducer = (state = 0, action) => {
+          dispatchedActions.push(action)
+          if (action.type === 'adjust') {
+            return action.isAdd ? state + action.amount : state - action.amount
+          }
+
+          return state
+        }
+
+        const store = createStore(reducer)
+        dispatchedActions = []
+
+        const Comp = () => {
+          const { adjust } = useActions({
+            adjust: (amount, isAdd = true) => ({
+              type: 'adjust',
+              amount,
+              isAdd
+            })
+          })
+
+          return (
+            <>
+              <button id="bInc1" onClick={() => adjust(1)} />
+              <button id="bInc2" onClick={() => adjust(2)} />
+              <button id="bDec1" onClick={() => adjust(1, false)} />
+            </>
+          )
+        }
+
+        const { container } = rtl.render(
+          <ProviderMock store={store}>
+            <Comp />
+          </ProviderMock>
+        )
+
+        const bInc1 = container.querySelector('#bInc1')
+        const bInc2 = container.querySelector('#bInc2')
+        const bDec1 = container.querySelector('#bDec1')
+
+        rtl.fireEvent.click(bInc1)
+        rtl.fireEvent.click(bInc2)
+        rtl.fireEvent.click(bDec1)
+
+        expect(dispatchedActions).toEqual([
+          { type: 'adjust', amount: 1, isAdd: true },
+          { type: 'adjust', amount: 2, isAdd: true },
+          { type: 'adjust', amount: 1, isAdd: false }
+        ])
+      })
+
+      // TODO: test for deps
+    })
+  })
+})
diff --git a/test/hooks/useDispatch.spec.js b/test/hooks/useDispatch.spec.js
new file mode 100644
index 0000000..c222911
--- /dev/null
+++ b/test/hooks/useDispatch.spec.js
@@ -0,0 +1,31 @@
+import React from 'react'
+import { createStore } from 'redux'
+import * as rtl from 'react-testing-library'
+import { Provider as ProviderMock, useDispatch } from '../../src/index.js'
+
+const store = createStore(c => c + 1)
+
+describe('React', () => {
+  describe('hooks', () => {
+    describe('useDispatch', () => {
+      afterEach(() => rtl.cleanup())
+
+      it("returns the store's dispatch function", () => {
+        let dispatch
+
+        const Comp = () => {
+          dispatch = useDispatch()
+          return <div />
+        }
+
+        rtl.render(
+          <ProviderMock store={store}>
+            <Comp />
+          </ProviderMock>
+        )
+
+        expect(dispatch).toBe(store.dispatch)
+      })
+    })
+  })
+})
diff --git a/test/hooks/useRedux.spec.js b/test/hooks/useRedux.spec.js
new file mode 100644
index 0000000..4ec5a8b
--- /dev/null
+++ b/test/hooks/useRedux.spec.js
@@ -0,0 +1,51 @@
+/*eslint-disable react/prop-types*/
+
+import React from 'react'
+import { createStore } from 'redux'
+import * as rtl from 'react-testing-library'
+import { Provider as ProviderMock, useRedux } from '../../src/index.js'
+
+describe('React', () => {
+  describe('hooks', () => {
+    describe('useRedux', () => {
+      let store
+      let renderedItems = []
+
+      beforeEach(() => {
+        store = createStore(({ count } = { count: -1 }) => ({
+          count: count + 1
+        }))
+        renderedItems = []
+      })
+
+      afterEach(() => rtl.cleanup())
+
+      it('selects the state and binds action creators', () => {
+        const Comp = () => {
+          const [count, { inc }] = useRedux(s => s.count, {
+            inc: () => ({ type: '' })
+          })
+          renderedItems.push(count)
+          return (
+            <>
+              <div>{count}</div>
+              <button id="bInc" onClick={inc} />
+            </>
+          )
+        }
+
+        const { container } = rtl.render(
+          <ProviderMock store={store}>
+            <Comp />
+          </ProviderMock>
+        )
+
+        const bInc = container.querySelector('#bInc')
+
+        rtl.fireEvent.click(bInc)
+
+        expect(renderedItems).toEqual([0, 1])
+      })
+    })
+  })
+})
diff --git a/test/hooks/useReduxContext.spec.js b/test/hooks/useReduxContext.spec.js
new file mode 100644
index 0000000..5d06046
--- /dev/null
+++ b/test/hooks/useReduxContext.spec.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import * as rtl from 'react-testing-library'
+import { useReduxContext } from '../../src/hooks/useReduxContext'
+
+describe('React', () => {
+  describe('hooks', () => {
+    describe('useReduxContext', () => {
+      afterEach(() => rtl.cleanup())
+
+      it('throws if component is not wrapped in provider', () => {
+        const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
+
+        const Comp = () => {
+          useReduxContext()
+          return <div />
+        }
+
+        expect(() => rtl.render(<Comp />)).toThrow(
+          /could not find react-redux context value/
+        )
+
+        spy.mockRestore()
+      })
+    })
+  })
+})
diff --git a/test/hooks/useSelector.spec.js b/test/hooks/useSelector.spec.js
new file mode 100644
index 0000000..a2fa272
--- /dev/null
+++ b/test/hooks/useSelector.spec.js
@@ -0,0 +1,247 @@
+/*eslint-disable react/prop-types*/
+
+import React from 'react'
+import { createStore } from 'redux'
+import * as rtl from 'react-testing-library'
+import { Provider as ProviderMock, useSelector } from '../../src/index.js'
+import { useReduxContext } from '../../src/hooks/useReduxContext'
+
+describe('React', () => {
+  describe('hooks', () => {
+    describe('useSelector', () => {
+      let store
+      let renderedItems = []
+
+      beforeEach(() => {
+        store = createStore(({ count } = { count: -1 }) => ({
+          count: count + 1
+        }))
+        renderedItems = []
+      })
+
+      afterEach(() => rtl.cleanup())
+
+      describe('core subscription behavior', () => {
+        it('selects the state on initial render', () => {
+          const Comp = () => {
+            const count = useSelector(s => s.count)
+            renderedItems.push(count)
+            return <div>{count}</div>
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Comp />
+            </ProviderMock>
+          )
+
+          expect(renderedItems).toEqual([0])
+        })
+
+        it('selects the state and renders the component when the store updates', () => {
+          const Comp = () => {
+            const count = useSelector(s => s.count)
+            renderedItems.push(count)
+            return <div>{count}</div>
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Comp />
+            </ProviderMock>
+          )
+
+          store.dispatch({ type: '' })
+
+          expect(renderedItems).toEqual([0, 1])
+        })
+      })
+
+      describe('lifeycle interactions', () => {
+        it('subscribes to the store synchronously', () => {
+          let rootSubscription
+
+          const Parent = () => {
+            const { subscription } = useReduxContext()
+            rootSubscription = subscription
+            const count = useSelector(s => s.count)
+            return count === 1 ? <Child /> : null
+          }
+
+          const Child = () => {
+            const count = useSelector(s => s.count)
+            return <div>{count}</div>
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Parent />
+            </ProviderMock>
+          )
+
+          expect(rootSubscription.listeners.get().length).toBe(1)
+
+          store.dispatch({ type: '' })
+
+          expect(rootSubscription.listeners.get().length).toBe(2)
+        })
+
+        it('unsubscribes when the component is unmounted', () => {
+          let rootSubscription
+
+          const Parent = () => {
+            const { subscription } = useReduxContext()
+            rootSubscription = subscription
+            const count = useSelector(s => s.count)
+            return count === 0 ? <Child /> : null
+          }
+
+          const Child = () => {
+            const count = useSelector(s => s.count)
+            return <div>{count}</div>
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Parent />
+            </ProviderMock>
+          )
+
+          expect(rootSubscription.listeners.get().length).toBe(2)
+
+          store.dispatch({ type: '' })
+
+          expect(rootSubscription.listeners.get().length).toBe(1)
+        })
+
+        it('notices store updates between render and store subscription effect', () => {
+          const Comp = () => {
+            const count = useSelector(s => s.count)
+            renderedItems.push(count)
+
+            // I don't know a better way to trigger a store update before the
+            // store subscription effect happens
+            if (count === 0) {
+              store.dispatch({ type: '' })
+            }
+
+            return <div>{count}</div>
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Comp />
+            </ProviderMock>
+          )
+
+          expect(renderedItems).toEqual([0, 1])
+        })
+      })
+
+      describe('performance optimizations and bail-outs', () => {
+        it('should shallowly compare the selected state to prevent unnecessary updates', () => {
+          store = createStore(
+            ({ count, stable } = { count: -1, stable: {} }) => ({
+              count: count + 1,
+              stable
+            })
+          )
+
+          const Comp = () => {
+            const value = useSelector(s => Object.keys(s))
+            renderedItems.push(value)
+            return <div />
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Comp />
+            </ProviderMock>
+          )
+
+          expect(renderedItems.length).toBe(1)
+
+          store.dispatch({ type: '' })
+
+          expect(renderedItems.length).toBe(1)
+        })
+      })
+
+      describe('edge cases', () => {
+        it('ignores transient errors in selector (e.g. due to stale props)', () => {
+          // TODO Not sure this test is really testing what we want.
+          // TODO The parent re-renders, which causes the child to re-run the selector anyway and throw the error.
+          // TODO Had to flip the assertion for now. Probably needs to be rethought.
+
+          const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
+          const Parent = () => {
+            const count = useSelector(s => s.count)
+            return <Child parentCount={count} />
+          }
+
+          const Child = ({ parentCount }) => {
+            const result = useSelector(({ count }) => {
+              if (count !== parentCount) {
+                throw new Error()
+              }
+
+              return count + parentCount
+            })
+
+            return <div>{result}</div>
+          }
+
+          rtl.render(
+            <ProviderMock store={store}>
+              <Parent />
+            </ProviderMock>
+          )
+
+          expect(() => store.dispatch({ type: '' })).toThrowError(
+            /while selecting the store state/
+          )
+
+          spy.mockRestore()
+        })
+
+        it('correlates the subscription callback error with a following error during rendering', () => {
+          const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
+
+          const Comp = () => {
+            const result = useSelector(count => {
+              if (count > 0) {
+                throw new Error('foo')
+              }
+
+              return count
+            })
+
+            return <div>{result}</div>
+          }
+
+          const store = createStore((count = -1) => count + 1)
+
+          const App = () => (
+            <ProviderMock store={store}>
+              <Comp />
+            </ProviderMock>
+          )
+
+          rtl.render(<App />)
+
+          expect(() => store.dispatch({ type: '' })).toThrow(
+            /The error may be correlated/
+          )
+
+          spy.mockRestore()
+        })
+      })
+
+      describe('error handling for invalid arguments', () => {
+        it('throws if no selector is passed', () => {
+          expect(() => useSelector()).toThrow()
+        })
+      })
+    })
+  })
+})