Table of Contents

Laboratorul 06: Gestionarea starii, Redux Toolkit si formulare.

Scopul laboratorului

In acest laborator vom intra in detaliu legat de gestionarea starii aplicatiei folosind hook-uri si Redux Toolkit. Motivul pentru care este nevoie de gestiunea starii aplicatiei este ca anumite componente in diferite locatii ale aplicatiei au nevoie sa imparta aceleasi date. O posibilitate pentru a propaga datele la mai multe componente este ca o compenta parinte sa trimita la descendentii sai datele prin proprietati din copil in copil, insa aceasta abordare poate aglomera componentele si duce la cod greu de gestionat. Alternativa cea mai buna este ca datele partajate de diferite componente sa fie puse la dispozitie printr-o stare globala accesibila prin functii speciale numite hooks. De asemnea, vom prezenta in acest laborator si modalitati de a crea formulare pentru a executa mutatii pe backend si cum puteti gestiona starea formularului cu validarea datelor introduse.

Gestionarea starii

La laboratoarele precedente a fost prezentata definirea componentelor ca extindere a clasei React.Component si definirea componentelor in mod functional. In cele mai multe cazuri daca componenta are nevoie sa de logica mai complexa este de preferat sa fie definita ca componenta functionala motivul fiind ca logica complexa a componentei poate fi sparta si chiar extrasa prin intermediul de hook-uri.

Un hook, asa cum a fost prezentat in laboratoarele precedente cu exemplu useState, este o functie speciala care este apelata de catre framework in mod automat cand variabilele de care depinde se modifica iar iesirile acestei functii daca se modifica vor declansa recalcularea componentei de catre framework.

Componentele functionale pot apela in interior hook-uri dar si hook-urile la randulor lor pot apela alte hook-uri, practic dezvoltatorul poate sa-si creeze propriile hook-uri particularizate din altele. Hook-urile ajuta ca logica sa unei componente sa fie separata de definirea UI-ului pentru a degreva componenta de anumite responsabilitati cum este gestionarea starii interne.

Mai jos este un exemplu de hook particularizat pentru gestionarea starii paginarii preluat din aplicatia 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
    }
}

Observati ca hook-ul se numeste incepand cu use ca sa se faca distinctia de alte functii in cod. Noi o sa numim hook-urile care contin logica unei componente controller hook. Mai jos e un exemplu de cum se poate folosi hook-ul precedent in altul impreuna cu alte hook-uri.

/**
 * 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, page, pageSize], () => query({ page, pageSize })); // Retrieve the table page from the backend via the query hook.
    const { mutateAsync: deleteMutation } = useMutation([deleteUserKey], deleteUser); // Use a mutation to remove an entry.
    const remove = useCallback(
        (id: string) => deleteMutation(id).then(() => queryClient.invalidateQueries([queryKey])),
        [queryClient, deleteMutation, queryKey]); // Create the callback to remove an entry.

    const tryReload = useCallback(
        () => queryClient.invalidateQueries([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
    };
}

In final, se poate folosi controller hook-ul in componenta de UI.

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 >
}

Sunteti liberi sa folositi cele prezentate sau nu, dar consideram ca aceasta abordare poate sa va ajute in organizarea codului si mentenabilitatea acestuia.

Redux Toolkit

Exista diferite biblioteci care implementeaza aceasta logica, React vine la pachet cu Context API si a fost istoric solutia implicita de a gestiona starea globala insa alte implementari au aparut si cea mai populara a devenit Redux.

Redux functioneaza intr-un mod foarte simplu, exista o stare globala initializata la incarcarea aplicatiei si pe starea globala se definesc tranzitii ca intr-un automat finit de stari. Tranzitiile se declanseza folosind useDispatch care returneaza o functie dispatch care poate sa trimita un obiect cu populat cu date pentru a declansa tranzitiile/mutatiile de stare, tranzitii care sunt definite intr-un reducer care practic este un switch si determina ce tranzitie se efectueaza pe baza datelor trimise prin dispatch. Starea efectiva se poate accesa prin useSelector pentru a returna o parte din starea globala, variabilele returnate se actualizeaza in mod automat oricunde apar in aplicatie cand se modifica starea globala prin functia de dispatch.

Chiar si asa, Redux simplu este destul de greu de folosit deoarece trebuie definite de dezvoltatori toti reduceri de mana cu fiecare tranzitie. Astfel peste Redux a fost creata 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 reduceri si de a gestiona starea globala prin stari mai mici denumite ca slice. Ca exemplu aveti mai jos cum se poate defini un slice si 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.
  }
});

Mai jos puteti folosi Redux pentru a face disponibila starea globala in aplicatie la descendentii 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>
)

Dupa definirea pentru Redux, starea se poate fi folosita si modificata ca in urmatorul exemplu.

  const { token } = useSelector(x => x.profileReducer); // You can use the data form the Redux storage.
  const dispatch = useDispatch();
  const logout = useCallback(() => {
    dispatch(resetProfile()); // Use the reducer action to create the payload for the dispatch.
  }, []);

Pentru mai multe detalii va rugam sa va uitati in aplicatia demo de pe gitlab-ul nostru.

Formulare

De multe ori o sa aveti nevoie de a crea formulare pentru a adauga/schimba date pe backend. In HTML aveti multe elemente care functioneaza ca input-uri pentru formulare, ex. select, textbox, checkbox etc. Pentru React bibliotecile de UI cum este si Material UI expun aceste input-uri deja stilizate si cu atribute care va pot ajuta pentru a controla componentele respective.

Problemele in definirea formularelor este atat controla componentelor din formular cat si validarea formularului, ne dorim ca formular sa valideze datele pana sa fie trimis catre backend cat si sa se schimbe la diferite actiuni ale utilizatorului cum ar fi pentru afisarea erorilor sau pentru ascunderea unur campuri. Pentru controlarea componentelor vom folosi react-hook-form iar pentru validarea formularului Yup:

npm install react-hook-form yup @hookform/resolvers

Folosind useForm putem obtine starea si actiuni de modificare pe starea unui formular dandu-se o anumita schema pentru formular iar cu yup putem definii o schema de validare pentru starea formularului care printr-un resolver va impiedica formularul sa fie submis daca nu se respecta schema de validare si va returna mesaje de eroare. In exemplul de mai jos aveti definirea unui formular simplu unde se defineste schema de validare si campurile formularului intr-un controller.

/**
 * Use a function to return the default values of the form and the validation schema.
 * You can add other values as the default, for example when populating the form with data to update an entity in the backend.
 */
const getDefaultValues = (initialData?: { email: string }) => {
    const defaultValues = {
        email: "",
        password: ""
    };

    if (!isUndefined(initialData)) {
        return {
            ...defaultValues,
            ...initialData,
        };
    }

    return defaultValues;
};

/**
 * Create a hook to get the validation schema.
 */
const useInitLoginForm = () => {
    const { formatMessage } = useIntl();
    const defaultValues = getDefaultValues();

    const schema = yup.object().shape({ // Use yup to build the validation schema of the form.
        email: yup.string() // This field should be a string.
            .required(formatMessage( // Use formatMessage to get the translated error message.
                { id: "globals.validations.requiredField" },
                {
                    fieldName: formatMessage({ // Format the message with other translated strings.
                        id: "globals.email",
                    }),
                })) // The field is required and needs a error message when it is empty.
            .email() // This requires the field to have a email format.
            .default(defaultValues.email), // Add a default value for the field.
        password: yup.string()
            .required(formatMessage(
                { id: "globals.validations.requiredField" },
                {
                    fieldName: formatMessage({
                        id: "globals.password",
                    }),
                }))
            .default(defaultValues.password),
    });

    const resolver = yupResolver(schema); // Get the resolver.

    return { defaultValues, resolver }; // Return the default values and the resolver.
}

/**
 * Create a controller hook for the form and return any data that is necessary for the form.
 */
export const useLoginFormController = (): LoginFormController => {
    const { formatMessage } = useIntl();
    const { defaultValues, resolver } = useInitLoginForm();
    const { redirectToHome } = useAppRouter();
    const { loginMutation: { mutation, key: mutationKey } } = useLoginApi();
    const { mutateAsync: login, status } = useMutation([mutationKey], mutation);
    const queryClient = useQueryClient();
    const dispatch = useDispatch();
    const submit = useCallback((data: LoginFormModel) => // Create a submit callback to send the form data to the backend.
        login(data).then((result) => {
            dispatch(setToken(result.response?.token ?? ''));
            toast(formatMessage({ id: "notifications.messages.authenticationSuccess" }));
            redirectToHome();
        }), [login, queryClient, redirectToHome, dispatch]);

    const {
        register,
        handleSubmit,
        formState: { errors }
    } = useForm<LoginFormModel>({ // Use the useForm hook to get callbacks and variables to work with the form.
        defaultValues, // Initialize the form with the default values.
        resolver // Add the validation resolver.
    });

    return {
        actions: { // Return any callbacks needed to interact with the form.
            handleSubmit, // Add the form submit handle.
            submit, // Add the submit handle that needs to be passed to the submit handle.
            register // Add the variable register to bind the form fields in the UI with the form variables.
        },
        computed: {
            defaultValues,
            isSubmitting: status === "loading" // Return if the form is still submitting or nit.
        },
        state: {
            errors // Return what errors have occurred when validating the form input.
        }
    }
}

Pentru componenta de UI trebuie folosit formularul in interiorul unui element de tip form unde se specifica ca atribut functia de submitere. In interiorul formularului se leaga variabilele la input-urile de form si ca sa se submita trebuie sa existe un buton de tip “submit” care va apela functia specificata in form cu datele din formular asa cum aveti ca exemplu mai jos.

export const LoginForm = () => {
    const { formatMessage } = useIntl();
    const { state, actions, computed } = useLoginFormController(); // Use the controller.

    return <form onSubmit={actions.handleSubmit(actions.submit)}> {/* Wrap your form into a form tag and use the handle submit callback to validate the form and call the data submission. */}
        <Stack spacing={4} style={{ width: "100%" }}>
            <ContentCard title={formatMessage({ id: "globals.login" })}>
                <Grid container item direction="row" xs={12} columnSpacing={4}>
                    <Grid container item direction="column" xs={12} md={12}>
                        <FormControl 
                            fullWidth
                            error={!isUndefined(state.errors.email)}
                        > {/* Wrap the input into a form control and use the errors to show the input invalid if needed. */}
                            <FormLabel required>
                                <FormattedMessage id="globals.email" />
                            </FormLabel> {/* Add a form label to indicate what the input means. */}
                            <OutlinedInput
                                {...actions.register("email")} // Bind the form variable to the UI input.
                                placeholder={formatMessage(
                                    { id: "globals.placeholders.textInput" },
                                    {
                                        fieldName: formatMessage({
                                            id: "globals.email",
                                        }),
                                    })}
                                autoComplete="username"
                            /> {/* Add a input like a textbox shown here. */}
                            <FormHelperText
                                hidden={isUndefined(state.errors.email)}
                            >
                                {state.errors.email?.message}
                            </FormHelperText> {/* Add a helper text that is shown then the input has a invalid value. */}
                        </FormControl>
                    </Grid>
                    <Grid container item direction="column" xs={12} md={12}>
                        <FormControl
                            fullWidth
                            error={!isUndefined(state.errors.password)}
                        >
                            <FormLabel required>
                                <FormattedMessage id="globals.password" />
                            </FormLabel>
                            <OutlinedInput
                                type="password"
                                {...actions.register("password")}
                                placeholder={formatMessage(
                                    { id: "globals.placeholders.textInput" },
                                    {
                                        fieldName: formatMessage({
                                            id: "globals.password",
                                        }),
                                    })}
                                autoComplete="current-password"
                            />
                            <FormHelperText
                                hidden={isUndefined(state.errors.password)}
                            >
                                {state.errors.password?.message}
                            </FormHelperText>
                        </FormControl>
                    </Grid>
                </Grid>
            </ContentCard>
            <Grid container item direction="row" xs={12} className="padding-top-sm">
                <Grid container item direction="column" xs={12} md={7}></Grid>
                <Grid container item direction="column" xs={5}>
                    <Button type="submit" disabled={!isEmpty(state.errors) || computed.isSubmitting}> {/* Add a button with type submit to call the submission callback if the button is a descended of the form element. */}
                        {!computed.isSubmitting && <FormattedMessage id="globals.submit" />}
                        {computed.isSubmitting && <CircularProgress />}
                    </Button>
                </Grid>
            </Grid>
        </Stack>
    </form>
};

Resurse utile