Application State
As React Application State is a forever evolving topic, there are various methods used across the Rebel front end stack.
The earliest React features at Rebel were largely done using Redux for app state management.
As an organization, we've moved away from this package. This was done main due to the inherent complexity and overhead of using Redux. The action/reducer life cycle and general file structure requires a deep understanding and heavy cognitive load for developers.
Examples of older features still currently using Redux are those found in rebel-web-src:
With the advent of React's Context API and Hooks, many use cases requiring global/app state management can be solved using including funcitonality without a 3rd party package.
*The latest versions of Redux use the Context API and hooks under the hood anyway.
Service-based state architecture
In borrowing concepts from frameworks like Angular, we separate application state by service types.
For example, User information is separate from order data, loaders, managers, etc...
By separating concerns this way, we can ensure that we keep state changes (and therefore re-renders) as low in the tree as possible. This also allows us to use certain parts of the application state only where they're directly needed. Product Managers are a fantastic example of this.
The Basic structure of our Service Providers and the Hooks needed to us them live within the rebel-web-ui package's providers folder.
There are three main parts to this Provider/Hook pattern:
- The Context instantiation
- The Provider definition
- The Hook funciton
The Context
The first thing necessary for this pattern to work is the creation of a React context variable
import * as React from 'react';
const MyContext = React.createContext();
The Provider
The basic Provider compent is supplied but the newly created Context. At this point, we want to create a new component where we can add the appropriate state and functions necessary.
import * as React from 'react';
const MyContext = React.createContext();
function MyProvider({ ...props }) {
const [someState, setSomeState] = React.useState({});
const businessyLogicFn = (value) => {
// do some businessy logic things
setSomeState({
...someState,
value,
});
};
const publicStuff = {
state: someState,
businessyLogicFn,
};
return <MyContext.Provider value={publicStuff} {...props} />;
}
At this point, we have a <MyProvider>
component which holds someState
as well as a function to perform some work.
What you'll notice is that we create and assign an object to the Provider's value prop. This becomes the data/functionality that will eventually be accessible to our components via the custom hook we'll be building. We have full control over both the shape and members of this globally available data.
For example, maybe we don't want our components to have direct access to the setSomeState
function. Maybe we want to rename someState
to state
. These are all things over which we have control.
Logic and use of state within this provider component can be very complex. It's important to note that since this is a component containing children of its own, those children are susceptible to re-renders whenever anything in the Provider changes.
useMemo
By leveraging the React.useMemo hook, we can use memoization to prevent unnecessary re-renders. The idea here is to create a memoized value, where you have control over which parts of your data actually trigger a change to propagate down the tree.
import * as React from 'react';
const MyContext = React.createContext();
function MyProvider({ ...props }) {
const [someState, setSomeState] = React.useState({});
const businessyLogicFn = (value) => {
// do some businessy logic things
setSomeState({
...someState,
value,
});
};
const publicStuff = React.useMemo(
() => ({
state: someState,
businessyLogicFn,
}),
[
someState
businessyLogicFn,
]
)
return <MyContext.Provider value={publicStuff} {...props} />;
}
Now, we're passing in a memoized value to our context provider.
The Hook
Lastly, we need a way to get this data and functionality to the components. For this, we have a few options:
The most basic way would be to expert the context variable itself to be imported into a child component of the Provider.
my-provider.js:export { MyProvider, MyContext };
my-child.jsimport { MyProvider, MyContext } from './my-provider';
/// Inside your components
const { state, businessyLogicFn } = React.useContext(MyContext);
The problem here, is that we have no check to see if the hook is used within the correct provider and exporting the context just feels weird.
The answer to this is an appropriately named custom hook in our provider file.
/// Context ...
/// Provider ...
// Hook
function useMyService() {
const myCtx = React.useContext(MyContext);
if (!myCtx) {
throw new Error(`useMyService must be used within MyProvider`);
}
return myCtx;
}
export { MyProvider, useMyService };
Now, we can ease our mental model and reference the named hook directly:
import { useMyService } from './my-provider'
...
const { state, businessyLogicFn } = useMyService()
...
Cool, right?!
In the event that we attempt to use the useMyService()
call in a component that isn't wrapped in <MyProvider>
, we get a friendly console error of "useMyService must be used within MyProvider" telling you which hook is acting up.