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