State management
In this section we will go into detail about managing application state using hook and Redux Toolkit functions. The reason application state management is needed is that certain components in different locations of the application need to share the same data. One possibility to propagate data to multiple components is for a parent component to pass data to its descendants via child-to-child properties, but this approach can clutter components and lead to hard-to-maintain code. The best alternative is to make data shared by different components available through a global state accessible via hook functions.
Hook functions
In the beginning, the definition of components as an extension of the React.Component class and the definition of components functionally were presented. In most cases if the component needs more complex logic it is preferable to be defined as a functional component the reason being that the complex logic of the component can be broken and even extracted more easily through hook functions.
A hook, such as useState or useEffect, is a special function that is called by React scripts automatically when the variables they depend on change and the outputs of this function will trigger the restoration of the visual component.
Functional components can internally call hook functions, but they can also call other hook functions. Basically, the developer can create their own custom hook functions from others. Hook functions help separate a component's logic from the UI definition to relieve the component of certain responsibilities such as managing internal state.
Below is an example of a custom hook function for managing paging state taken from our demo application.
/**
* This is the pagination controller hook that can be used to manage the state for paged entries.
*/
export const usePaginationController = () => {
const [page, setPage] = useState(1); // Create a state for the current page.
const [pageSize, setPageSize] = useState(10); // Create a state for the current page size.
const setPagination = useCallback((newPage: number, newPageSize: number) => { // Create a callback to set both the current page and page size.
setPage(newPage);
setPageSize(newPageSize);
}, [setPage, setPageSize]);
return { // Return the state and its mutations.
page,
pageSize,
setPage,
setPageSize,
setPagination
}
}
Note that the hook function is named starting with "use" to distinguish it from other normal functions. We will call hook functions that contain the logic of a component controller hook. Below is an example of how to use the previous hook function in another along with others.
/**
* This is controller hook manages the table state including the pagination and data retrieval from the backend.
*/
export const useUserTableController = () => {
const { getUsers: { key: queryKey, query }, deleteUser: { key: deleteUserKey, mutation: deleteUser } } = useUserApi(); // Use the API hook.
const queryClient = useQueryClient(); // Get the query client.
const { page, pageSize, setPagination } = usePaginationController(); // Get the pagination state.
const { data, isError, isLoading } = useQuery({
queryKey: [queryKey, page, pageSize],
queryFn: () => query({ page, pageSize })
}); // Retrieve the table page from the backend via the query hook.
const { mutateAsync: deleteMutation } = useMutation({
mutationKey: [deleteUserKey],
mutationFn: deleteUser
}); // Use a mutation to remove an entry.
const remove = useCallback(
(id: string) => deleteMutation(id).then(() => queryClient.invalidateQueries({ queryKey: [queryKey] })),
[queryClient, deleteMutation, queryKey]); // Create the callback to remove an entry.
const tryReload = useCallback(
() => queryClient.invalidateQueries({ queryKey: [queryKey] }),
[queryClient, queryKey]); // Create a callback to try reloading the data for the table via query invalidation.
const tableController = useTableController(setPagination, data?.response?.pageSize); // Adapt the pagination for the table.
return { // Return the controller state and actions.
...tableController,
tryReload,
pagedData: data?.response,
isError,
isLoading,
remove
};
}
Finally, the controller hook function can be used in the UI component, thus separating the logic of the component from the visual display.
export const UserTable = () => {
const { userId: ownUserId } = useAppSelector(x => x.profileReducer);
const { formatMessage } = useIntl();
const header = useHeader();
const orderMap = header.reduce((acc, e, i) => { return { ...acc, [e.key]: i } }, {}) as { [key: string]: number }; // Get the header column order.
const { handleChangePage, handleChangePageSize, pagedData, isError, isLoading, tryReload, labelDisplay, remove } = useUserTableController(); // Use the controller hook.
const rowValues = getRowValues(pagedData?.data, orderMap); // Get the row values.
return <DataLoadingContainer isError={isError} isLoading={isLoading} tryReload={tryReload}> {/* Wrap the table into the loading container because data will be fetched from the backend and is not immediately available.*/}
<UserAddDialog /> {/* Add the button to open the user add modal. */}
{!isUndefined(pagedData) && !isUndefined(pagedData?.totalCount) && !isUndefined(pagedData?.page) && !isUndefined(pagedData?.pageSize) &&
<TablePagination // Use the table pagination to add the navigation between the table pages.
component="div"
count={pagedData.totalCount} // Set the entry count returned from the backend.
page={pagedData.totalCount !== 0 ? pagedData.page - 1 : 0} // Set the current page you are on.
onPageChange={handleChangePage} // Set the callback to change the current page.
rowsPerPage={pagedData.pageSize} // Set the current page size.
onRowsPerPageChange={handleChangePageSize} // Set the callback to change the current page size.
labelRowsPerPage={formatMessage({ id: "labels.itemsPerPage" })}
labelDisplayedRows={labelDisplay}
showFirstButton
showLastButton
/>}
...
</DataLoadingContainer >
}
We recommend the approach presented with controller hook functions to modularize and keep the code as simple as possible. The biggest impact of this approach will be seen in complex components like forms.
Redux Toolkit
There are various libraries that implement logic for managing a global state. React comes bundled with the Context API and has historically been the default solution to manage global state but other implementations have emerged and the most popular has become *Redux *.
Redux works in a very simple way, there is a global state initialized when the application loads and transitions are defined on the global state as in a finite state machine. Transitions are triggered using the useDispatch hook function, this returns a dispatch function that can also dispatch an object populated with data to trigger state transitions/mutations. Transitions are defined in a reducer object which is basically a switch and determines which transition is performed based on the data sent by dispatch. The actual state can be accessed via the useSelector hook function to return part of the global state. The returned variables are automatically updated wherever they appear in the application when the global state changes via the dispatch function.
Even so, simple Redux is quite hard to use because developers have to define all the reducer objects by hand with each transition. Thus on top of Redux was created the library of Redux Toolkit which can be installed via npm:
npm install react-redux @reduxjs/toolkit
The Redux Toolkit exposes simpler methods of creating reducer objects and managing global state through smaller states named as slices. As an example, below is how a slice and a reduction can be defined.
/**
* Use constants to identify keys in the local storage.
*/
const tokenKey = "token";
/**
* This decodes the JWT token and returns the profile.
*/
const decodeToken = (token: string | null): ProfileState => {
let decoded = token !== null ? jwtDecode<{ nameid: string, name: string, email: string, exp: number }>(token) : null;
const now = Date.now() / 1000;
if (decoded?.exp && decoded.exp < now) {
decoded = null;
token = null;
localStorage.removeItem(tokenKey);
}
return {
loggedIn: token !== null,
token: token ?? null,
userId: decoded?.nameid ?? null,
name: decoded?.name ?? null,
email: decoded?.email ?? null,
exp: decoded?.exp ?? null
};
};
/**
* The reducer needs a initial state to avoid non-determinism.
*/
const getInitialState = (): ProfileState => decodeToken(localStorage.getItem(tokenKey)); // The initial state doesn't need to come from the local storage but here it is necessary to persist the JWT token.
/**
* The Redux slice is a sub-state of the entire Redux state, Redux works as a state machine and the slices are subdivisions of it for better management.
*/
export const profileSlice = createSlice({
name: "profile", // The name of the slice has to be unique.
initialState: getInitialState(), // Add the initial state
reducers: {
setToken: (_, action: PayloadAction<string>) => { // The payload is a wrapper to encapsulate the data sent via dispatch. Here the token is received, saved and a new state is created.
localStorage.setItem(tokenKey, action.payload);
return decodeToken(action.payload); // You can either return a new state or change it via the first parameter that is the current state.
},
resetProfile: () => { // This removes the token from the storage and resets the state.
localStorage.removeItem(tokenKey);
return {
loggedIn: false,
token: null,
userId: null,
name: null,
email: null,
exp: null
};
}
}
});
export const {
setToken,
resetProfile
} = profileSlice.actions; // Export the slice actions, they are used to wrap the data that is send via the dispatch function to the reducer.
export const profileReducer = profileSlice.reducer; // Export the reducer.
export const store = configureStore({
reducer: {
profileReducer // Add more reducers here as needed.
}
});
To be able to use Redux to make global state available in your application you must have components as children of the Provider component.
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
{/* The Provider adds the storage for Redux. */}
<Provider store={store}>
...
</Provider>
</React.StrictMode>
)
After defining the state for Redux, it can be used and modified as in the following example.
export type AppState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
const { token } = useAppSelector(x => x.profileReducer); // You can use the data form the Redux storage.
const dispatch = useAppDispatch();
const logout = useCallback(() => {
dispatch(resetProfile()); // Use the reducer action to create the payload for the dispatch.
}, []);