Redux on a leash

Adrián Ferrera González

Who Am I?

  • Full Stack Developer at Lean Mind
  • Passionate about development
  • Typescript lover ❤️

Adrián Ferrera González

@afergon

adrian-afergon

Lean Coders

What's Lean Mind???

Devs - Team - Family

What do we do at Lean Mind?

Back-end

Front-end

Mobile

Redux

What is this?

PATTERN

Libs / Frameworks

How it born?

HttpClient

AuthService

UserClient

UserRepository

ProductsService

Component 😥

Component 😥

Component 😥

Problems

  • Multiple services
  • Multiple components
  • Multiple states with same data
  • Communication between service
  • Communication between components

Co-author

  • Co-athor of redux
  • Create React App
  • Work at facebook

Dan Abramov

@dan_abramov

@dan_abramov

gaearon

How it works

😍

But is complex...

An actions is an event with 2 parts

class AddItemsToCart implements Action {
  readonly type = EQUIP;
  constructor(public variantIds: VariantId[]) {}
}
const addItemsToCart = (variantIds: VariantId[]) => ({
   type: CartActionsTypes.ADD_ITEMS_TO_CART,
   variantsId,
});

It depends on the framework

Many concepts:

type: string

payload: any

State is a serializable info

{
    users: {
        "1a34": {
            "name": "Jason Todd"
        },
        "2f2d": {
            "name": "Dick Grayson"
        }
    },
    products: {
        "3a4b": {
            "title": "Batgarang",
            "price": 10.65
        },
    }
}

Many concepts:

And is the language of redux

ALWAYS

Reducers must be pure functions

//This is inpure
function notPureFoo(state, action) {
    state.user = action.payload;
    return state;
}

// This is pure
function Purefoo(state, action) {
    return {...state, user: action.payload}
}

And return new state

Many concepts:

export function gauntletReducer(
    state: UserState = defaultState, 
    action: UserActions) {
  switch (action.type) {
    case SET_USER:
      return {
        ...state, 
        [action.user.id]: {
            ...state[action.user.id], 
            ...action.user} 
        }
    };
    default:
      return state;
  }
}

A simple switch

But we hate switchs...

const handler = {
    [SET_USER]: (
        state: UserState, 
        action: UserAction
    ) => ({
       ...state, 
        [action.user.id]: {
            ...state[action.user.id], 
            ...action.user} 
        } 
    }) 
}

Call an API is not pure...

and async

However, we will have to handle it also with actions

Many concepts:

@adrian.afergon

Documentation

IS A TRAP

DOCUMENTATION !== REALITY

The PROBLEM:

Patronitis

The nature of humans are use patters, recipes, or mechanist everithing

And we want it

Mandelbrot set

- by Adrián Ferrera, dec 2018

And is it all?

If you want a clean architecture is not enough

Yes

...or not

In the best case:

Data

Layers

But is not enough

@adrian.afergon

We will need some tips as:

  • DTOS
  • ViewModels
  • Mappers
  • Computed props
  • Domain models
  • A lot of experience

Resume:

We will need a layer isolation and

Driven Domain Design (DDD)

What the hell is this?

  • DTO: Data transfer object. POJOS.
  • Domain model: Class to manage our data and make operations.
  • View Models: Class definitios used in our componens.

Transform

Data Models

  • Mapper: Change the data structure.
    Ex: DTO to State.
  • Computed props: Manage data for view.
    Ex: TotalPrice = sum(products)

BACKEND

Mapper

Selector

Benefits

  • Isolation
  • Reuse
  • Mantenibility
  • Easy tests

Resume:

  • Productivity

Elegant complexity

Mike Ryan

Do you need it?

... I don't know

  • It depends on the requirements of  your business.
  • It depends on whether you can bear the costs
  • It depends on your teammates... or who comes after

EXAMPLES

Examples:

Normalized state

export interface ProductsState {
  readonly products: Product[];
  readonly packReady: Product[];
  readonly customPack: Product[];
  readonly homeGeneralConcern: ConcernTags | GeneralConcernTags;
  readonly filters: ProductFilter;
  readonly filterOptions: ProductFilterOptions;
  readonly isLoading: boolean;
}

Backend overlap

Why?

[ProductActionTypes.GET_PRODUCT_FULFILLED]: (
    state: ProductsState, 
    action: GetProductByHandleFulfilledAction) => {
    
    const hasTheSameHandle = (handle: string) => (product: Product) => product.handle === handle;

    const indexOfProduct = state.products.findIndex(hasTheSameHandle(action.product.handle));
    const newProducts = [...state.products];

    if (indexOfProduct !== -1) {
      newProducts[indexOfProduct] = action.product;
    } else {
      newProducts.push(action.product);
    }

    return {
      ...state,
      products: [...newProducts],
      isLoading: false,
      messages: {
        [ProductActionTypes.GET_PRODUCT]: emptyMessage,
      },
    };
  },

...Only for update one product 

@adrian.afergon

Solution:

export interface ProductsState {
  readonly products: { [key: string]: Product };
  readonly packReady: { [key: string]: Product };
  readonly customPack: { [key: string]: Product };
  readonly homeGeneralConcern: ConcernTags | GeneralConcernTags;
  readonly filters: ProductFilter;
  readonly filterOptions: ProductFilterOptions;
  readonly isLoading: boolean;
}

WAIT!!

export interface ProductsState {
  readonly products: Normalized<Product>;
  readonly packReady: Normalized<Product>;
  readonly customPack: Normalized<Product>;
  readonly homeGeneralConcern: ConcernTags | GeneralConcernTags;
  readonly filters: ProductFilter;
  readonly filterOptions: ProductFilterOptions;
  readonly isLoading: boolean;
}
export interface Normalized<T> {
  [Key: string]: T;
}

And the reducer:

[ProductActionTypes.GET_PRODUCT_FULFILLED]: (
    state: ProductsState, 
    action: GetProductByHandleFulfilledAction) => ({
    
    ...state,
    products: {
      ...state.products,
      [action.product.id]: {
        action.product
        },
      }
  }),

Spoiler alert!!!

The infinite State / Deep State

interface Product {
    name: string,
    price: number
}

interface ProductList {
    name: string,
    createAt: Date,
    updatedAt: Date,
    shared: true,
    owner: userId,
    products: Normalized<Product>
}

interface User {
    name: string,
    productLists: Normalized<ProductList>
}

interface AppState {
    users: Normalize<User>
}

And what happen if two users have a ProductList shared an one update it?

Problem: Is not a single source of truth

Example of deep state:

Suppose an app for a Shop with.

Solution:

type ProductId = string;
type ListId = string; 

interface Product {
    name: string,
    price: number
}

interface ProductList {
    name: string,
    createAt: Date,
    updatedAt: Date,
    shared: true,
    owner: userId,
    products: ProductId[]
}

interface User {
    name: string,
    productLists: ListId[]
}

interface AppState {
    users: Normalized<User>,
    lists: Normalized<ProductList>,
    products: Normalized<Products>
}

Now you can update a list or a product, and it will be updated for all users at same time

Ununderstandable effects

export const onIncrementLineItemFromCart: Epic<
  AllActions,
  UpdateItemFromCartFulfilledAction | UpdateItemFromCartRejectedAction
> = (action$, state$, { checkoutClient }: ObservableDependencies) =>
  action$.pipe(
    ofType(CartActionsTypes.INCREASE_LINE_ITEM_QUANTITY),
    switchMap((action: IncreaseLineItemQuantityAction) => {
      if (state$.value.cart.checkoutId) {
        const theLineItem: LineItem = state$.value.cart.items.find(
          (lineItem: LineItem) => lineItem.id === action.lineItemId,
        );

        const lineItems = [
          {
            id: theLineItem.id,
            quantity: theLineItem.quantity + 1,
          } as LineItem,
        ];
        return checkoutClient.checkoutLineItemsUpdate(lineItems, state$.value.cart.checkoutId).pipe(
          map((response: CheckoutDTO) => updateItemFromCartFulfilled(response)),
          catchError(error => of(updateItemFromCartRejected(error))),
        );
      }
      return of(updateItemFromCartRejected('Usted no tiene un carrito asignado'));
    }),
  );

And of course we have twins

export const onDecrementLineItemFromCart: Epic<
  AllActions,
  UpdateItemFromCartFulfilledAction | UpdateItemFromCartRejectedAction
> = (action$, state$, { checkoutClient }: ObservableDependencies) =>
  action$.pipe(
    ofType(CartActionsTypes.DECREASE_LINE_ITEM_QUANTITY),
    switchMap((action: DecreaseLineItemQuantityAction) => {
      if (state$.value.cart.checkoutId) {
        const theLineItem: LineItem = state$.value.cart.items.find(
          (lineItem: LineItem) => lineItem.id === action.lineItemId,
        );

        const lineItems = [
          {
            id: theLineItem.id,
            quantity: theLineItem.quantity - 1,
          } as LineItem,
        ];
        return checkoutClient.checkoutLineItemsUpdate(lineItems, state$.value.cart.checkoutId).pipe(
          map((response: CheckoutDTO) => updateItemFromCartFulfilled(response)),
          catchError(error => of(updateItemFromCartRejected(error))),
        );
      }
      return of(updateItemFromCartRejected('Usted no tiene un carrito asignado'));
    }),
  );

SOLID?

Single Responsability?

First Step: Extract logic

import { LineItem } from '../infrastructure/cart/cart.reducer';
import { CheckoutClient } from '../client/Shopify';
import { Observable } from 'rxjs';
import { CheckoutDTO } from '../client/Shopify/CheckoutClient';

export class CartService {

  private checkoutClient: CheckoutClient;

  constructor(checkoutClient: CheckoutClient) {
    this.checkoutClient = checkoutClient;
  }

  public incrementLineItemFromCart(
    checkoutId: string,
    lineItemId: string,
    items: LineItem[]
  ): Observable<CheckoutDTO> {
    const theLineItem: LineItem = items.find(
      (lineItem: LineItem) => lineItem.id === lineItemId,
    );

    const lineItems = [
      {
        id: theLineItem.id,
        quantity: theLineItem.quantity + 1,
      } as LineItem,
    ];

    return this.checkoutClient.checkoutLineItemsUpdate(lineItems, checkoutId);
  }

  public decrementLineItemFromCart(
    checkoutId: string,
    lineItemId: string,
    items: LineItem[]
  ): Observable<CheckoutDTO> {
    const theLineItem: LineItem = items.find(
      (lineItem: LineItem) => lineItem.id === lineItemId,
    );

    const lineItems = [
      {
        id: theLineItem.id,
        quantity: theLineItem.quantity - 1,
      } as LineItem,
    ];
    return this.checkoutClient.checkoutLineItemsUpdate(lineItems, checkoutId)



  }

}

We can do it better 🤔

but... is an example 🙄  

Second Step: Clean our Effect

export const onAddItemsToCart: Epic<AllActions, AddItemsToCartFulfilledAction | AddItemsToCartRejectedAction> = (
  action$,
  state$,
  { checkoutClient }: ObservableDependencies,
) =>
  action$.pipe(
    ofType(CartActionsTypes.ADD_ITEMS_TO_CART),
    switchMap((action: AddItemsToCartAction) => {
      const cartService = new CartService(checkoutClient);

      return cartService.addLineItemToCheckout(state$.value.cart.checkoutId, action.variantsId).pipe(
        map((response: CheckoutDTO) => addItemsToCartFulfilled(response)),
        catchError(error => of(addItemsToCartRejected(error))),
      );
    }),
  );

...at less redux don't know about it

Other better... 😏

export const onAddItemsToCart: Epic<
    AllActions, 
    AddItemsToCartFulfilledAction | AddItemsToCartRejectedAction
> = (
  action$,
  state$,
  { checkoutClient }: ObservableDependencies,
) =>
  action$.pipe(
    ofType(CartActionsTypes.ADD_ITEMS_TO_CART),
    switchMap((action: AddItemsToCartAction) => {
      const lineItems = action.variantsId.map(variantId => ({
        variantId,
        quantity: 1,
      }));

      if (state$.value.cart.checkoutId) {
        const checkoutId = state$.value.cart.checkoutId;
        return addLineItemToCheckout(checkoutClient, lineItems, checkoutId);
      } else {
        return createCheckoutWithLineItem(checkoutClient, lineItems);
      }
    }),
  );

Problems:

  • Redux know about logic
  • Code bifurcation
  • Difficult debug
const addLineItemToCheckout = (
    checkoutClient: CheckoutClient, 
    lineItems: LineItem[], 
    checkoutId: string
) =>
  checkoutClient.checkoutLineItemsAdd(lineItems, checkoutId).pipe(
    map((response: CheckoutDTO) => addItemsToCartFulfilled(response)),
    catchError(error => of(addItemsToCartRejected(error))),
  );

const createCheckoutWithLineItem = (
    checkoutClient: CheckoutClient, 
    lineItems: LineItem[]
) =>
  checkoutClient.createCheckout(lineItems).pipe(
    map((response: any) => createCheckoutWithLineItemFulfilled(response)),
    catchError(error => of(addItemsToCartRejected(error))),
  );

And a same actions for success:

export function addItemsToCartFulfilled(checkout: CheckoutDTO) {
  return {
    type: CartActionsTypes.ADD_ITEMS_TO_CART_FULFILLED,
    checkout,
  };
}
export type AddItemsToCartFulfilledAction = 
    ReturnType<typeof addItemsToCartFulfilled>;


export function createCheckoutWithLineItemFulfilled(checkout: CheckoutDTO) {
  return {
    type: CartActionsTypes.CREATE_CHECKOUT_WITH_LINE_ITEM_FULFILLED,
    checkout,
  };
}
export type CreateCheckoutWithLineItemFulfilledAction = 
    ReturnType<typeof createCheckoutWithLineItemFulfilled>;

The solution:

export class CartService {
// [...]
public addLineItemToCheckout(checkoutId: string, variantIds: string[]) {
    const lineItems = variantIds.map(variantId => ({
      variantId,
      quantity: 1,
    }));

    return checkoutId
      ? this.checkoutClient.checkoutLineItemsAdd(lineItems, checkoutId)
      : this.checkoutClient.createCheckout(lineItems);
  }
}
export const onAddItemsToCart: Epic<
    AllActions, 
    AddItemsToCartFulfilledAction | AddItemsToCartRejectedAction
> = (
  action$,
  state$,
  { checkoutClient }: ObservableDependencies,
) =>
  action$.pipe(
    ofType(CartActionsTypes.ADD_ITEMS_TO_CART),
    switchMap((action: AddItemsToCartAction) => {
      const cartService = new CartService(checkoutClient);
      return cartService.addLineItemToCheckout(
        state$.value.cart.checkoutId, 
        action.variantsId
    ).pipe(
        map((response: CheckoutDTO) => addItemsToCartFulfilled(response)),
        catchError(error => of(addItemsToCartRejected(error))),
      );
    }),
  );

State is the language of redux

Understand

We shouldn't talk to it in other

Resume

  • Should not be your first concern.
  • Not use as boilerplate.
  • It add mantenibility at the expense of complexity
  • Examples !== Reality

And is it all?

YES

... if you survived

Questions?

Because we want Flutter... 🙄

Thank you a lot!!

GDG Redux on a leash

By afergon

GDG Redux on a leash

V2 of redux on a leash for GDG Gran Canaria 2019

  • 743