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