- react-connect-context-hooks
Install the library and peer-dependencies:
npm install --save react-connect-context-hooks scheduler
NOTE: scheduler
is a peerDependency
from use-context-selector
, which allows re-rendering only the affected components reading a modifying prop from the whole state.
react-connect-context-hooks
allows you to implement a state-management solution using React.hooks, while leverages good practices and provide DRY code.
This library takes ideas and patterns from redux
and its ecosystem and integrates them all under a lightweight and easy to learn package; e.g.:
- Splitting the
state
per feature-modules. - Defining
actionCreators
to dispatch actions (higher-order functions likeredux-thunk
). - Providing a
connect
HOC for selecting values from your state. - Computing derived data (like
reselect
).
Also, enforces good practices for implementing state-management with hooks; e.g.:
- Provides custom
Provider
HOC for wrapping your components. - Provides custom
useContext
hook for selecting what you need from the store (similar to theconnect
HOC mentioned above). - Provides different
selection
or mapping options for easily getting what you need from thestore
. - Prevents unnecessary re-renders implementing basic memoization on top of your components.
Other exiting features:
- When the state is updated, it only re-renders components directly reading from any of the modified props (instead of re-rendering all consumer components as Context do by default). This is achieved by implementing
use-context-selector
library internally.
Since React hooks are available, a lot of discussion among the developer-community arose regarding if external tooling (such as redux
) is needed to implement global state management in your apps.
While it is possible to implement a simple redux-like functionality just with useReducer
+ useContext
, there are good recommendations and practices to follow, especially for growing applications.
This library allows you implementing good practices for your custom state-management solution using React hooks while reducing the boilerplate for every Provider/Consumer
you need to create.
In this section, we will review the basic functionality provided by the library. The snippets are based on the example application located in
examples/counter
. For a more complete/advanced use-case take a look atexamples/todomvc
.
Let's go through this example by explaining what is happening on the different files:
/// CounterProvider.tsx
import counterReducer, { initialState } from './counterReducer';
import counterActions from './counterActions';
import createContextProvider, { connectContextFactory, useConnectedContextFactory } from 'react-connect-context-hooks';
const [CounterProvider, CounterContext] = createContextProvider(counterReducer, initialState, counterActions);
const withCounter = connectContextFactory(CounterContext);
const useCounter = useConnectedContextFactory(CounterContext);
export default CounterProvider;
export {
withCounter,
useCounter,
};
createContextProvider
receives a reducer
function, an initialState
object, and an object with actions
; it returns:
- a
Provider
component for wrapping your application and allowing its children to have access to the underlyingContext
. - a
Context
object that we use next to create our customconnect
HOC and/oruseContext
hook (withCounter
anduseCounter
in the example).
Note: you can check below the documentation for defining actions
and reducer
.
Next, use the Provider
component for wrapping your application's code.
/// App.tsx
import React from 'react';
import CounterProvider from './counter/store';
import Counter from './counter/Counter';
const App: React.FC = () => {
return (
<CounterProvider>
<Counter />
</CounterProvider>
);
}
export default App;
After creating and configuring your Provider you can connect your components with the Context using the HOC helper as follows:
/// CounterComponent.tsx
import React from 'react';
import { withCounter } from './store/CounterProvider';
const Counter: React.FC = ({ count, increment, decrement }) => {
const [amount, setAmount] = React.useState(1);
const updateAmount = (event: any) => {
setAmount(parseInt(event.target.value));
}
return (
<div>
<h1>Counter Component</h1>
<p>
<b>Amount:</b>
<input type="number" value={amount} onChange={updateAmount} />
</p>
<p>
<b>Count: </b>
<span>{count}</span>
</p>
<hr />
<button onClick={() => decrement(amount)}>Decrement</button>
<button onClick={() => increment(amount)}>Increment</button>
</div>
)
}
// Export isolated component is recommended for unit-testing
export {
Counter,
}
// Export the connected component as default for using in your application
export default withCounter(Counter, {
stateSelectors: ['count'],
actionSelectors: ['increment', 'decrement'],
});
In this example, we define a simple Counter
component expects 3 properties: count
, increment
and decrement
. We will provide these values by selecting from our Context
; e.g.:
withCounter
is a HOC that allows connecting toCounterContext
.- this HOC function receives the
Counter
component and some options for selecting data from yourstore
(check below how to define selectors).
In general, using the HOC helper is the recommended way to access your Context (for testing purposes and separations of concerns), but if desired you can also use the hook helper. Let's see how to recreate the same example as above using useCounter
hook; e.g.:
/// CounterComponent.tsx
import React from 'react';
import { useCounter } from './store/CounterProvider';
const Counter: React.FC = () => {
const [amount, setAmount] = React.useState(1);
const { count, increment, decrement } = useCounter({
stateSelectors: ['count'],
actionSelectors: ['increment', 'decrement'],
});
const updateAmount = (event: any) => {
setAmount(parseInt(event.target.value));
}
return (
<div>
<h1>Counter Component</h1>
<p>
<b>Amount:</b>
<input type="number" value={amount} onChange={updateAmount} />
</p>
<p>
<b>Count: </b>
<span>{count}</span>
</p>
<hr />
<button onClick={() => decrement(amount)}>Decrement</button>
<button onClick={() => increment(amount)}>Increment</button>
</div>
)
}
export default Counter;
useCounter
receives the same options
object as the HOC helper, so you can select only what you need from the store.
https://edriang.github.io/react-connect-context-hooks
Check these examples to better understand the library and get inspiration to write your next awesome app!
- Counter App
This is a simple Counter application; it uses a reducer to manage state and connects components to the store using the hooks helpers.
https://github.com/edriang/react-connect-context-hooks/tree/master/examples/counter
- Todo MVC
This is a more complex and complete App; these are some of the features it uses:
- Stores the application data on multiple Contexts, each one with its own state and actions.
- Merges all context into a single StoreProvider for easier access.
- Uses
onInit
prop to fetch initial data when the Provider renders for the first time. - Connects components to the store using HOC helpers.
- Derives state using
computeSelectors
.
https://github.com/edriang/react-connect-context-hooks/tree/master/examples/counter
Actions are higher-order functions that receive two parameters:
dispatch
: a function used to trigger an action to be handled by the reducer.getState
: a function to retrieve a reference to the current state values of your store.
This function MUST return a function. The returned function can be defined as you like with zero or more parameters and even as async
.
Note: if you ever used redux-thunk
you will notice the similarities.
/// counterActions.ts
const ACTIONS = {
INCREMENT: 'INCREMENT',
DECREMENT: 'DECREMENT',
};
const increment = (dispatch: any, getState: Function) => (amount: number) => {
dispatch({
type: ACTIONS.INCREMENT,
payload: { amount },
});
}
const decrement = (dispatch: any, getState: Function) => (amount: number) => {
dispatch({
type: ACTIONS.DECREMENT,
payload: { amount },
});
}
const actions = {
increment,
decrement,
}
export default actions;
export {
ACTIONS,
};
Note: for accessing these actions you will use the actionSelectors
option as described above.
This is a regular reducer function; you can check React.useReducer
documentation to get more familiar with it; e.g.:
/// counterReducer.ts
import { ACTIONS } from './counterActions';
type CounterState = {
count: number;
}
const initialState: CounterState = {
count: 0,
};
function reducer(state: any, action: any) {
const { amount } = action.payload;
switch (action.type) {
case ACTIONS.INCREMENT:
return { ...state, count: state.count + amount };
case ACTIONS.DECREMENT:
return { ...state, count: state.count - amount };
default:
return state;
}
}
export default reducer;
export {
initialState,
};
You can use selectors
for retrieving data from your store. You define selections by using stateSelectors
and/or actionSelectors
options.
A selection can be defined in different ways, either using an Array, an Object or a Function as specified below.
You can pass an Array of keys you'd like to pick from the store; e.g.:
['count', 'user']
In case you want to assign a different name to the properties on the resulting object, you can use the following syntax:
['user:loggedInUser']
This will get the value of the user
property from the store and will provide it to the component as loggedInUser
prop (similar to object destructuring with assignation).
You can select nested properties by providing a path
; e.g.:
['user.firstName:userName']
In this case, we are selecting the property firstName
from user
, and then returning with the name userName
.
If in the example above you didn't provide :userName
, then the returning value will be assigned with a key equals to the last part of the selection path; e.g.:
['user.firstName']
This will select user.firstName
from the store and return it as firstName
on the resulting selection.
The path
notation also works for selecting values from an array; e.g.:
['todos[0].title:firstTaskTitle']
You can pass an Object of key:value
pairs; e.g.:
{
loggedInUser: 'user',
userName: 'user.firstName',
}
There are similarities to working with Arrays:
- You can select a property from the store (
user
) and assign a different name (loggedInUser
) in the resulting props. - You can use a
path
for selecting nested properties as well.
One special behavior using Object selectors is it allows specifying a getter Function
; this function will be called with the state
and props
of your component (the latter only available with connect
HOC); e.g.:
{
fullName: (state, props) => {
return `${state.user.firstName}${props.separator}${state.user.lastName}`;
},
}
Lastly, you can specify a Function
to create the resulting object from the state
. This function will also receive the components props
when used with the connect HOC; e.g.:
(state, props) => {
return {
isLoggedIn: Boolean(state.user),
fullName: `${state.user.firstName}${props.separator}${state.user.lastName}`,
count: state.count,
}
}
Tip: If your store is not a key/value map, you can use Function
to retrieve the whole state of the store; e.g.:
// Assuming your store is an Array of `todos` instead of { todos }:
(todos) => todos
Sometimes you'll need to call some service when the application starts so you can populate your store with initial data.
To cover that scenario, the Provider
returned by createContextProvider
accepts a special property called onInit
, which expects to receive a tuple (array) with two values: a selections object and a function.
The selection object
is the same as the one used with connect HOC and allows selecting only what you need from your store, as well as applying derived state functions.
The function
provided as second parameter will be called with the result of the previous selection.
Note that, if provided, onInit
function will be triggered only once when the Provider is first rendered; e.g.:
// index.tsx
const selectionOption = {
actionSelectors: ['fetchTodos'],
}
const onInit({ fetchTodos }) => fetchTodos();
render(
<TodosProvider onInit={[selectionOption, onInit]}>
<App />
</TodosProvider>
document.getElementById('root')
)
There are scenarios in which you'll need to access more than one Context
to gather all the values your component needs. In such cases you can use mergedConnectContextFactory
helper function; e.g.:
import { MainContext } from './main/store';
import { TodosContext } from './todos/store';
const TodosComponent = ({ mainStateProp, todosStateProp, todosActionProp, anotherProp}) => { }
const withMainAndTodos = mergedConnectContextFactory({
main: MainContext,
todos: TodosContext,
});
export default withMainAndTodos(TodosComponent, {
stateSelectors: ['main.stateProp:mainStateProp', 'todos.stateProp:todosStateProp'],
actionSelectors: ['todos.actionProp:todosActionProp'],
});
This helper function is similar to connectContextFactory
, but instead receives a dictionary of Context
objects.
Then, you can use regular selectors
for retrieving data from any of the specified store contexts; the only consideration you should keep in mind is that now you'll need to specify the name (key) provided on mergedConnectContextFactory
Context-dictionary (in the example main
and todos
).
Note: the stores' data will be merged together before applying selectors
so you have access to the value of all the contexts.
The use case for Merged Stores is similar to "Combining Contexts" as explained above, but the main difference is it allows you to create one merged store with all your providers for wrapping your App; e.g.:
// store.tsx
import { createMergedStore } from 'react-connect-context-hooks';
import MainProvider from './main/store';
import TodosProvider from './todos/store';
const [StoreProvider, withStore, useStore] = createMergedStore({
main: MainProvider,
todos: TodosProvider
});
export default StoreProvider;
export {
withStore,
useStore,
};
Note you provide a dictionary to createMergedStore
; this is important to keep in mind, as now your selectors must be prefixed with this key. This is necessary to avoid property-collisions between different stores.
Then you can wrap your App
using this only provider:
// index.tsx
import React from 'react';
import { render } from 'react-dom';
import App from './main/components/App';
import StoreProvider, { useStore } from './store';
const onInit = ({ fetchTodos }: any) => {
fetchTodos();
}
const selection = {
// As stated above, now you should use the `todos` prefix assigned to `TodosProvider` on previous step
actionSelectors: ['todos.fetchTodos'],
}
render(
<StoreProvider onInit={[selection, onInit]}>
<App />
</StoreProvider>,
document.getElementById('root')
)
As you can see, StoreProvider
also support onInit
function for triggering an action when your Provider is rendered.
Notice that createMergedStore
also returns two additional values:
withStore
: will connect any component with all your merged stores context values.useStore
: is a hook that will let you access all your merged stores context values.
It is a good practice to save in the store the minimum data and then derive or compute any other value your components might need; e.g.:
- Let's say you are developing a TO-DO app and you want to display lists of
todos
filtered by some criteria: `ALL', 'COMPLETED', 'PENDING'. - In this case, instead of storing and having to maintain 3 different lists for each filter, a better approach is to store the full list and current filter; then, you can use
computedSelector
to retrieve the filtered list.
// TodosComponent.ts
import { withTodos } from './todos/store';
const TodosComponent = ({ todos }) => { /* your components' code here */ }
// This is the selector function
const filterVisibleTodos(todos, visibilityFilter) {
switch(visibilityFilter) {
case 'COMPLETED': return todos.filter(todo => todo.completed);
case 'UNCOMPLETED': return todos.filter(todo => !todo.completed);
default: return todos;
}
}
export default withTodos(TodosComponent, {
stateSelectors: ['todos', 'visibilityFilter'],
computedSelectors: {
todos: [filterVisibleTodos, ['todos', 'visibilityFilter']],
},
});
The computedSelectors
option expects an object with the following signature:
- the
key
defines the name associated with the resulting object. - the
values
expect a tuple (array) with two elements:- the first element is a function that will be called to compute the resulting value.
- the second element is an array of dependencies (used for memoization).
Note: the dependencies
array can list properties coming from any of the following sources:
- the result of previous selections (e.g.
stateSelectors
orstateSelectors
). - the original
props
provided to the components. - previous returned values by other
computedSelectors
functions.
Important: computedSelectors
are memoized using React.memo()
; this avoids re-computing a giving selector if none of the listed dependencies changed.
There are cases in which you'll need to add some values to a Context to make them available to every component, but those values won't change so often, for example, to keep track of authentication. In those cases, creating a reducer
might be a little overkill, so you can skip it and use the library as follows:
*Check this codesandbox for quickly testing this approach, or check the auth0 example located at
examples/react-spa
;
// authStore.js
import createContextProvider, { connectContextFactory } from 'react-connect-context-hooks';
import authService from './authService';
const loginAction = (dispatch, getState) => async (username, password) => {
dispatch({ loading: true });
try {
const user = await authService.login(username, password);
dispatch({ loading: false, user, isAuthenticated: true });
} catch (error) {
dispatch({ loading: false, error });
}
}
const actions = {
login: loginAction
}
const initialState = {
isAuthenticated: false,
user: {},
loading: false,
error: null,
}
const [AuthProvider, AuthContext] = createContextProvider(initialState, actions);
const withAuth = connectContextFactory(AuthContext);
export default AuthProvider;
export {
withAuth,
};
As you can see, we didn't provide a reducer
and instead just provided the initial state and actions.
You'll notice that actions
are created as regular actionCreators
; they receive a dispatch
and getState
functions and must return a function that will be called by consumers/connected components.
The main difference is that you'll call dispatch
with an object to patch
(update) your current state, instead of triggering reducer actions to produce the mutations.
And that's it for the store... for completeness, below you have an example of how will look a component using this approach:
// App.js
import React from 'react';
import { withAuth } from "./authStore";
const App = ({ loading, isAuthenticated, user, login, logout }) => {
if (loading) {
return <span>Loading...</span>
}
if (isAuthenticated) {
return (
<div>
<h1>{`Hello ${user.name}!!`}</h1>
<button onClick={logout}>Log out</button>
</div>
)
}
return <button onClick={() => login("test", "pass")}>Log in</button>
}
export default withAuth(App, {
stateSelectors: ['loading', 'isAuthenticated', 'user'],
actionSelectors: ['login', 'logout'],
});
Finally, remember to wrap your App with the context provider:
// index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import AuthProvider from "./authStore";
const rootElement = document.getElementById("root");
ReactDOM.render(
<AuthProvider>
<App />
</AuthProvider>,
rootElement
);
The main benefit of a Context
based state-management solution is that you can co-locate your state closer to the components using it. This approach goes against having a big-global state for your entire app (like in Redux).
Having co-located state is a good practice specially if you are using client-side routing and lazy-loading, because you are going to load and unload pieces of the UI and when those sections are detached the state related to them should probably be garbage-collected too.
On the other hand, in the scenarios where you need to have part of your state global, you can easily move the provider related to that specific state higher on the components hierarchy.
Combining different Contexts
to create a merged store is also something possible with this library. You can create a combined provider (Store
) or merge them on demmand whenever you need it.
Then, you can use selectors
to access specific pieces of your state and trust this library to update the underlying components only when some of those properties changes. This leads to important performance benefits against using React.ContextAPI
by your own (if you are not cautious).
Using the HOC is the recommended way to connect your components with your Context because it makes unit-testing easier.
The main idea is that you will export your connected component as the default export and your dumb/presentational component as your named export; let's review the following example:
export const LoginForm = ({ error, login }) => {
const [username, setUsername] = React.useState();
function onSubmit(event) {
event.preventDefault();
login(username);
}
return (
<form onSubmit={onSubmit}>
{error && <pre>{error}</pre>}
<input data-testid="input-username" value={username} onChange={e => setUsername(e.target.value)} />
<button type="submit" data-testid="button-login">Log In</button>
</form>
);
};
export default withCounter(LoginForm, {
stateSelectors: ['error'],
actionSelectors: ['login'],
});
As you can see, the default export is the HOC that connects your LoginForm
with the Context. You don't need to test this default export, as this library is already tested and you don't have to worry about its internal behavior.
What you'd want to test is your LoginForm
component isolated; this is why we are exporting it in addition to the default export.
Following this approach you'll just need to create test-assertions to validate that your component behaves as expected with the received props; here is an example using jest
and react-testing-library
:
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';
const mockData = {
error: null,
login: jest.fn()
};
describe('LoginForm', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders LoginForm component', () => {
const { queryByText, getByTestId } = render(<LoginForm {...mockData} />);
expect(queryByText(mockData.error)).toBeFalsy();
expect(getByTestId('input-username')).toBeTruthy();
expect(getByTestId('button-login')).toBeTruthy();
});
it('renders error', () => {
const error = 'Some error';
const { getByText, getByTestId } = render(<LoginForm {...mockData} error={error} />);
expect(getByText(error)).toBeTruthy();
expect(getByTestId('input-username')).toBeTruthy();
expect(getByTestId('button-login')).toBeTruthy();
});
it('triggers login fn', () => {
const { getByTestId } = render(<LoginForm {...mockData} />);
fireEvent.click(getByTestId('button-login'));
expect(mockData.login).toHaveBeenCalled();
});
});
Consider the following example file:
// CounterWithHooks.tsx
import React from 'react';
import { useCounter } from './store';
const Counter: React.FC = () => {
const { count, increment, decrement } = useCounter({
stateSelectors: ['count'],
actionSelectors: ['increment', 'decrement'],
});
const [amount, setAmount] = React.useState(1);
const updateAmount = (event: any) => {
setAmount(parseInt(event.target.value));
}
return (
<div>
<h1>Counter Component</h1>
<p>
<b>Amount:</b>
<input type="number" value={amount} onChange={updateAmount} />
</p>
<p>
<b>Count: </b>
<span>{count}</span>
</p>
<hr />
<button onClick={() => decrement(amount)}>Decrement</button>
<button onClick={() => increment(amount)}>Increment</button>
</div>
)
}
export default Counter;
As you can notice, this component is accessing directly the Context values using the useCounter
hook (check CounterProvider.tsx
example).
As it uses useContext
internally we need to provide the component with a Context; for this purpose, we can use createMockProvider
utility function.
createMockProvider
receives the Provider
component (the one created with createContextProvider
) and a ReactNode
; it returns a MockProvider
component which can be used to provide the Context values to your component. The MockProvider
component accepts two properties: state
and actions
.
Take a look at the following test file:
// `CounterWithHooks.test.tsx`
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { createMockProvider } from 'react-connect-context-hooks';
import CounterProvider from './store/CounterProvider';
import Counter from './CounterWithHooks';
const MockProvider = createMockProvider(CounterProvider, <Counter />);
const mockedState = {
count: 999,
};
const mockedActions = {
increment: jest.fn(),
decrement: jest.fn(),
};
describe('Counter', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders counter component', () => {
const { getByText } = render(<MockProvider state={mockedState} actions={mockedActions} />);
expect(getByText(String(mockedState.count))).toBeTruthy();
});
it('calls increment when press increment button', () => {
const { getByText } = render(<MockProvider state={mockedState} actions={mockedActions} />);
const button = getByText('Increment');
fireEvent.click(button);
expect(mockedActions.increment).toHaveBeenCalledTimes(1);
});
it('calls decrement when press decrement button', () => {
const { getByText } = render(<MockProvider state={mockedState} actions={mockedActions} />);
const button = getByText('Decrement');
fireEvent.click(button);
expect(mockedActions.decrement).toHaveBeenCalledTimes(1);
});
});
Please feel free to open an issue on github.