Why you should normalize your Redux State
Redux as a local database
Objectif :
L'utilisateur ne télécharge jamais deux fois la même donnée
Impact :
- Réduit les temps d'attente
- Réduit la consommation réseau
- Augmente la qualité du code front
Redux as a local database
- Redux charge dans le state la donnée, elle est utilisable dans toute l'app
- Stratégie de mise à jour optimiste avec un minimum de loaders (cf formation hors ligne de NicolasD)
API Rest existante
Modèle de données = design de la page
Utilisation REST de l'API
export const parseApiFilmShowsMovies = (movieFilmShows: FilmShowsMovieApiType[]) => (
movieFilmShows.map((filmShows: FilmShowsMovieApiType) => ({
...translateMovieApiType(filmShows.film),
filmShows: (filmShows.seancesAndVersionTypes || []).map(translateFilmShowApiType),
}))
);
export const parseApiFilmShowsCinemas = (cinemaFilmShows: FilmShowsCinemaApiType[]): CinemaByIdRegionType[] => {
const regions = groupFilmShowsByRegion(cinemaFilmShows);
const sortedRegions = sortBy(regions, region => region.order);
return sortedRegions.map(region => getRegionWithSortedCinemas(region));
};
UGC data model V1
declare type FilmShowType = {
cinemaId?: number,
movieId?: number,
version?: string,
hasNumber: boolean,
id: number,
date: number,
dateTimezone: string,
isFull: boolean,
}
declare type CinemaType = {
id: number,
filmShows: FilmShowsType[],
showingDates?: moment$Moment[],
movies?: MovieType[],
isFavorite: boolean,
distance: number,
name: string,
address: string,
order: number,
zipCode: string,
services: string[],
city: string,
}
declare type MovieType = {
id: number,
showingDates?: moment$Moment[],
showingRegions?: CinemaRegionType[],
filmShows: FilmShowsType[],
actors: string,
director: string,
releaseDate: string,
posterUrl: string,
title: string,
director: string,
kind: string,
label: string,
duration: string,
synopsis: string,
video: ?string,
pegiRating: string,
}
Modification inApp des Cinémas Favoris
- Deux sources de vérité :
- l'API
- Redux
- Duplication des cinémas dans autant de pages films visitées
UGC data model V2
declare type FilmShowType = {
cinemaId: number,
movieId: number,
version: string,
hasNumber: boolean,
id: number,
date: number,
dateTimezone: string,
isFull: boolean,
}
declare type CinemaType = {
id: number,
showingDates?: moment$Moment[],
isFavorite: boolean,
distance: number,
name: string,
address: string,
order: number,
zipCode: string,
services: string[],
city: string,
}
declare type MovieType = {
id: number,
showingDates?: moment$Moment[],
showingRegions?: CinemaRegionType[],
actors: string,
director: string,
releaseDate: string,
posterUrl: string,
title: string,
director: string,
kind: string,
label: string,
duration: string,
synopsis: string,
video: ?string,
pegiRating: string,
}
UGC data model (normalizr)
// Define a users schema
const movie = new schema.Entity('movies', {}, {
idAttribute: 'film_id',
});
// Define your comments schema
const cinema = new schema.Entity('cinemas', {}, {
idAttribute: 'code_complexe',
});
const filmShow = new schema.Entity('filmShows', {}, {
idAttribute: 'seance_id',
processStrategy: (value, parent, key) => {
value.version = parent.version;
return value;
},
});
// Define your article
const filmShowMovie = new schema.Array({
cinema,
seancesAndVersionTypes: [{
seances: [filmShow]
}]
});
const normalizedData = normalize(body, filmShowMovie);
UGC data model V3
declare type FilmShowType = {
cinemaId: number,
movieId: number,
version: string,
hasNumber: boolean,
id: number,
date: number,
dateTimezone: string,
isFull: boolean,
}
declare type CinemaType = {
id: number,
showingDates?: moment$Moment[],
name: string,
address: string,
order: number,
zipCode: string,
services: string[],
city: string,
}
declare type MovieType = {
id: number,
showingDates?: moment$Moment[],
actors: string,
director: string,
releaseDate: string,
posterUrl: string,
title: string,
director: string,
kind: string,
label: string,
duration: string,
synopsis: string,
video: ?string,
pegiRating: string,
}
Utilisation de maps
declare type CinemasStateType = {
map: {[id: number]: CinemaType},
byRegion: {[region: string]: number[]},
favorites: number[],
nearby: number[],
}
How to communicate between sagas
export function* login(action: { email: string, password: string }): SagaType {
const loginResponse = yield call(Api.login, action.email, action.password);
const token = loginResponse.headers.authorization;
yield put({ type: actionTypes.LOGIN.SUCCESS, authentication: { token } });
// How to fetch cinemas to get Favorites ?
Toast.show('Connexion réussie !');
}
export function* loginSaga(): SagaType {
yield takeLatest(actionTypes.LOGIN.REQUEST, catchApiExceptions(addLoader(login)));
}
export function* FetchCinemasSaga(): SagaType {
yield takeLatest(actionTypes.FETCH_CINEMAS.REQUEST, catchApiExceptions(fetchCinemas));
}
Communicate between sagas
export function* login(action: { email: string, password: string }): SagaType {
const loginResponse = yield call(Api.login, action.email, action.password);
const token = loginResponse.headers.authorization;
yield put({ type: actionTypes.LOGIN.SUCCESS, authentication: { token } });
Toast.show('Connexion réussie !');
}
export function* loginSaga(): SagaType {
yield takeLatest(actionTypes.LOGIN.REQUEST, catchApiExceptions(addLoader(login)));
}
export function* FetchCinemasSaga(): SagaType {
// Trigger FetchCinemasSaga on actionTypes.LOGIN.SUCCESS
yield takeLatest([
actionTypes.FETCH_CINEMAS.REQUEST,
actionTypes.LOGIN.SUCCESS,
],
catchApiExceptions(fetchCinemas)
);
}
Event pattern
export function* login(action: { email: string, password: string }): SagaType {
const loginResponse = yield call(Api.login, action.email, action.password);
const token = loginResponse.headers.authorization;
yield put({ type: actionTypes.LOGIN.SUCCESS, authentication: { token } });
// Fetch cinemas to get Favorites
yield put({ type: cinemasActionTypes.FETCH_CINEMAS.REQUEST });
Toast.show('Connexion réussie !');
}
export function* loginSaga(): SagaType {
yield takeLatest(actionTypes.LOGIN.REQUEST, catchApiExceptions(addLoader(login)));
}
export function* FetchCinemasSaga(): SagaType {
yield takeLatest(actionTypes.FETCH_CINEMAS.REQUEST, catchApiExceptions(fetchCinemas));
}
Sequential pattern
Choix final : Séquentiel
- La vérité est-elle dans l'action ou dans la Saga ?
- Le fetchCinema conclut la saga de login, on fait le choix séquentiel
Handle Loaders and Errors like a Boss
// Error decorator
export const catchApiExceptions = (saga: any, timeout: number = API_TIMEOUT) =>
function* (...args: any): SagaType {
try {
const { hasTimeOuted } = yield race({
hasTimeOuted: call(delay, timeout),
executeApiSaga: saga.apply(this, args),
});
if (hasTimeOuted) {
Toast.show('Erreur de connexion au serveur, veuillez réessayer plus tard.');
}
yield put(stopLoadingCreator());
} catch (error) {
handleApiException(error);
yield put(stopLoadingCreator());
}
};
// ---
// In your Sagas
export function* watchFetchCinemasSaga(): SagaType {
yield takeLatest(actionTypes.FETCH_CINEMAS.REQUEST, catchApiExceptions(fetchCinemas));
}
Handle errors
// Loader decorator
export const loaderReducer = (state: LoaderStateType = initialState, action: ActionType): LoaderStateType => {
switch (action.type) {
case actionTypes.START_LOADING:
return { ...state, isLoading: true };
case actionTypes.STOP_LOADING:
return { ...state, isLoading: false };
default:
return state;
}
};
export const addLoader = (saga: any) =>
function* (...args: any): SagaType {
yield put(startLoadingCreator());
yield saga.apply(this, args);
yield put(stopLoadingCreator());
};
// ---
// In your Sagas
export function* watchFetchCinemasSaga(): SagaType {
yield takeLatest(actionTypes.FETCH_CINEMAS.REQUEST, addLoader(fetchCinemas));
}
// In App.js
render() {
return (
<View>
<Content />
<OverlayLoader />
</View>
)
}
Handle loaders V1
// Reducer
case actionTypes.START_LOADING:
return { ...state, isLoading: true };
case actionTypes.STOP_LOADING:
return { ...state, isLoading: false };
// Loader decorator
export const addPageLoader = (saga: any, loaderName: string) =>
function* (...args: any): SagaType {
yield put(startLoadingPageCreator(loaderName));
yield saga.apply(this, args);
yield put(stopLoadingPageCreator(loaderName));
};
// In your Sagas
export function* watchFetchCinemasSaga(): SagaType {
yield takeLatest(actionTypes.FETCH_CINEMAS.REQUEST, addLoader(fetchCinemas, 'cinemas'));
}
// In Cinemas.js
render() {
return (
<View>
<LoaderWrapper loaderName="cinemas">
<Content />
</LoaderWrapper>
</View>
)
}
Handle loaders V2
Write your own
Saga Effects
// CUSTOM EFFECT
export function* makeAuthenticatedCall(apiCall: any): SagaType {
const token = yield select(getAuthenticationToken);
const response = yield call(() => Api.addAuthorizationHeader(apiCall, token));
return response.body;
}
export function authenticatedCall(apiCall: any): SagaType {
return call(makeAuthenticatedCall, apiCall);
}
// ----
// In your Saga
export function* fetchFavoriteCinemas(): SagaType {
yield authenticatedCall(
Api.fetchFavoriteCinemas(),
);
}
Authenticated Call
Refresh your data with an initial Saga
export function* loadInitialData(): SagaType {
const isAuthenticated: boolean = yield select(isAuthenticated);
yield fetchMovies();
yield fetchCinemas();
if (isAuthenticated) {
yield fetchBookings();
yield fetchFavorites();
}
}
Initial Saga
Handle authentication vs anonymous data
I logged out,
but I still see my bookings in the bookings tab
Bonus:
Listen to app State changes
import { takeLatest } from 'redux-saga/effects';
import { FOREGROUND, BACKGROUND, INACTIVE } from 'redux-enhancer-react-native-appstate';
function* appHasComeBackToForeground() {
// app has come back to foreground!
}
function* watchForAppBackToForeground() {
yield takeLatest(
FOREGROUND,
catchApiExceptions(appHasComeBackToForeground),
);
}
Listen to app State changes
Formation UGC
By Nicolas Ngô-Maï
Formation UGC
- 300