Gestionarea stării
În această secțiune vom intra în detaliu legat de gestionarea stării aplicației folosind funcții hook și Redux Toolkit. Motivul pentru care este nevoie de gestionarea stării aplicației este că anumite componente în diferite locații ale aplicației au nevoie să împartă aceleași date. O posibilitate pentru a propaga datele la mai multe componente este ca o compenta părinte să trimită la descendenții săi datele prin proprietăti din copil în copil, însa această abordare poate aglomera componentele și duce la cod greu de menținut. Alternativa cea mai bună este ca datele partajate de diferite componente să fie puse la dispoziție printr-o stare globală accesibilă prin funcții hook.
Funcții hook
La inceput a fost prezentata definirea componentelor ca extindere a clasei React.Component și definirea componentelor în mod funcțional. În cele mai multe cazuri dacă componenta are nevoie de logică mai complexă este de preferat să fie definită ca componentă funcțională motivul fiind că logica complexă a componentei poate fi spartă și chiar extrasă mai usor prin intermediul de funcțiilor hook.
Un hook, ca de exemplu useState sau useEffect, este o funcție speciala care este apelată de catre scripturile de React în mod automat când variabilele de care depind se modifică iar ieșirile acestei funcții vor declansa refacerea componentei vizuale.
Componentele funcționale pot apela în interior funcșii hook dar și acestea la randulor lor pot apela alte funcții hook. Practic, dezvoltatorul poate să-și creeze propriile funcții hook particularizate din altele. Funcțile hook ajută ca logica unei componente să fie separată de definirea UI-ului pentru a degreva componenta de anumite responsabilități cum este gestionarea stării interne.
Mai jos este un exemplu de funcție hook particularizată pentru gestionarea stării paginării preluată din aplicația demo a nostra.
/**
* 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
}
}
Observați că funcția hook se numește începând cu "use" ca să se facă distincșia de alte funcții normale. Noi o să numim funcții hook care conțin logica unei componente controller hook. Mai jos e un exemplu de cum se poate folosi funcția hook precedentă în alta împreună cu altele.
/**
* 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
};
}
În final, se poate folosi funcția controller hook în componenta de UI segreg ând astfel logica componentei de afișarea vizuală.
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 >
}
Recomandăm abordarea prezentată cu funcții controller hook pentru a modulariza și menține codul cât mai simplu. Impactul cel mai mare a acestei abordări se va putea vedea în componente complexe precum formulare.
Redux Toolkit
Există diferite biblioteci care implementează logica pentru gestiunea unei stări globale. React vine la pachet cu Context API și a fost istoric soluția implicită de a gestiona starea globală însă alte implementări au apărut și cea mai populară a devenit Redux.
Redux funcționează într-un mod foarte simplu, există o stare globală inițializată la încărcarea aplicației și pe starea globală se definesc tranziții ca într-un automat finit de stări. Tranzițiile se declanșează folosind funcția hook useDispatch, aceasta returneaza o funcție dispatch care poate și trimitș un obiect populat cu date pentru a declanșa tranzițiile/mutațiile de stare. Tranzițiile sunt definite într-un obiect reducer care practic este un switch și determinș ce tranziție se efectuează pe baza datelor trimise prin dispatch. Starea efectivă se poate accesa prin funcția hook useSelector pentru a returna o parte din starea globală. Variabilele returnate se actualizează în mod automat oricunde apar în aplicație când se modifică starea globală prin funcția de dispatch.
Chiar și așa, Redux simplu este destul de greu de folosit deoarece trebuie definite de dezvoltatori toate obiectele reducer de mână cu fiecare tranziție. Astfel peste Redux a fost creată biblioteca de Redux Toolkit care se poate instala prin npm:
npm install react-redux @reduxjs/toolkit
Redux Toolkit expune metode mai simple de a crea obiecte reducer și de a gestiona starea globală prin stări mai mici denumite ca slice. Ca exemplu aveți mai jos cum se poate defini un slice și un reducer.
/**
* 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.
}
});
Ca să puteți folosi Redux pentru a face disponibilă starea globală în aplicașie trebuie și aveți componentele ca descendenți ai componentei Provider.
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
{/* The Provider adds the storage for Redux. */}
<Provider store={store}>
...
</Provider>
</React.StrictMode>
)
După definirea stării pentru Redux, aceasta poate fi folosită și modificată ca în urmatorul exemplu.
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.
}, []);