El patrón BLoC junto a Clean Architecture en ReactJS
En el anterior artículo vimos un ejemplo en Flutter de como pueden encajar el patrón BLoC y Clean Architecture.
Como viene siendo habitual, ahora vamos a ver la versión de ReactJS.
Refactorizando el ejemplo Shopping Cart
Vamos a trabajar sobre el mismo ejemplo del carrito de la compra que iniciamos en el artículo de gestion simple de estado en ReactJS.
En este ejemplo estabamos utilizando Clean Architecture, Model View Presenter y la estrategia de levantar estado junto con perforación de propiedades, que es la más básica en frameworks declarativos.
Vamos a refactorizar ese ejemplo pasando a utilizar el patrón BloC.
La idea es sustituir los presenters por blocs y sustituir la perforación de propiedades y eventos por interacciones con los blocs.
Decidiendo estrategia de bloc pattern a utilizar
Como vimos en la introdución al patrón bloc, la idea del patrón bloc es tener un intermediario entre la vista y el modelo, que en este caso al utilizar clean architecture sería el dominio, con un estado observable.
Este estado observable es lo que nos va a permitir compartir estado entre diferentes vistas del arbol.
Partiendo de esta idea base del patrón BLoC, como ya vimos en el artículo de introducción existen diferentes implementaciones.
En este caso en ReactJS, de nuevo no voy a utilizar ninguna librería externa. Como ya dije en el anterior, creo que la mejor forma de aprender inicialmente cosas nuevas es hacértelo tu mismo y luego ya cuando lo tienes asimilado, es cuando creo que es el momento de utilizar librerías si se considera oportuno.
Para este ejemplo, al igual que en el de Flutter, voy a seguir la implementación de tener varias actiones de entrada en el bloc, a través de un método por cada acción y un único estado observable que representa la vista.
Infraestructura necesaria
Vamos a ver la infraestructura que necesitamos para poder implementar el patrón bloc.
Bloc base
Debido la implementación del patrón bloc que voy a utilizar, encaja bastante bien tener una clase base bloc de la que estiendan los demás blocs y que contenga el comportamiento en común.
type Subscription<S> = (state: S) => void;
abstract class Bloc<S> {
private internalState: S;
private listeners: Subscription<S>[] = [];
constructor(initalState: S) {
this.internalState = initalState;
}
public get state(): S {
return this.internalState;
}
changeState(state: S) {
debugger;
this.internalState = state;
if (this.listeners.length > 0) {
this.listeners.forEach(listener => listener(this.state));
}
}
subscribe(listener: Subscription<S>) {
this.listeners.push(listener);
}
unsubscribe(listener: Subscription<S>) {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
}
}
export default Bloc;
Vamos a tener un tipo subcription que consiste en una función que recibe un estado. Este va a ser el tipo de los listener que se pueden suscribir a cambios de estado del BloC, y podremos tener un array de listeners por cada BloC.
Cada vez que una clase derivada nos comunique un cambios, se lo notificaremos a los listener suscritos.
También tendremos un metodo unsubcribe para que las vistas (listeners) se desuscriban.
createContext
Vamos a definir un Bloc por cada pantalla o concepto importante y si el estado de un bloc es compartido y tiene que ser observado por hijos necesitamos tener un mecanismo de acceso al bloc desde los componentes hijos inferiores.
Para conseguir esto, en ReactJS podemos conseguirlo mediente el concepto de context, el hook useContext.
Para evitar bastante boilerplate para crearnos individualmente un contexto y su useContext personalizado para cada BloC, podemos tener un método de factoría que nos ahorre trabajo.
export function createContext<T>() {
const context = React.createContext<T | undefined>(undefined);
function useContext() {
const ctx = React.useContext(context);
if (!ctx) throw new Error("context must be inside a Provider with a value");
return ctx;
}
return [context, useContext] as const;
}
BlocBuilder
Este es un componente que nos va a ahorrar todo el trabajo de suscripción y desuscripción al bloc en el useEffect y reconstrucción cuando el estado cambia.
interface BlocBuilderProps<B extends Bloc<S>, S> {
bloc: B;
builder: (state: S) => JSX.Element;
}
const BlocBuilder = <B extends Bloc<S>, S>({bloc, builder}: BlocBuilderProps<B, S>) => {
const [state, setState] = useState(bloc.state);
useEffect(() => {
const stateSubscription = (state: S) => {
setState(state);
};
bloc.subscribe(stateSubscription);
return () => bloc.unsubscribe(stateSubscription);
}, [bloc]);
return builder(state);
};
export default BlocBuilder;
Hay que pasarle el bloc y un builder. Se encarga de suscribirse a los cambios del bloc y desuscribirse cuando el componente es destruido y renderiza el componente de nuevo cuando hay cambios de estado.
Refactorizando el carrito de la compra
Este ejemplo consta de dos partes, por una lado los productos y por otro el carrito de la compra.
Al igual que en el artículo donde iniciamos el ejemplo, vamos a centrarnos en el carrito de la compra porque es donde vamos a ver la potencia del patrón bloc al compartir su estado por diferentes vistas.
Refactorizamos el estado
Primero vamos a refactorizar el estado definido anteriormente para contemplar todos los tipos de estado posibles como loading, error y updated que extienden de una misma clase base.
De esta forma aprovechamos la capacidad de TypeScript de tener discriminated union types o sum types que junto con pattern matching hace que podamos modelar nuestro estado de una forma más robusta.
De esta forma estamos definiendo todos los estados posibles ya que el carrito no podrá encontrarse en dos estados de cada tipo a la vez, solo es posible tener un estado de los definidos.
export interface CommonCartState {
open: boolean;
}
export interface LoadingCartState {
kind: "LoadingCartState"
}
export interface UpdatedCartState {
kind: "UpdatedCartState";
items: Array<CartItemState>;
totalPrice: string;
totalItems: number;
}
export interface ErrorCartState {
kind: "ErrorCartState"
error: string;
}
export type CartState = (LoadingCartState | UpdatedCartState | ErrorCartState) & CommonCartState
export interface CartItemState {
id: string;
image: string;
title: string;
price: string;
quantity: number;
}
export const cartInitialState: CartState = {
kind: "LoadingCartState",
open: false
}
Reemplazando CartPresenter por CartBloc
Ahora vamos a crearnos el bloc para el carrito.
export class CartBloc extends Bloc<CartState> {
constructor(
private getCartUseCase: GetCartUseCase,
private addProductToCartUseCase: AddProductToCartUseCase,
private removeItemFromCartUseCase: RemoveItemFromCartUseCase,
private editQuantityOfCartItemUseCase: EditQuantityOfCartItemUseCase) {
super(cartInitialState);
this.loadCart();
}
closeCart() {
this.changeState({ ...this.state, open: false });
}
openCart() {
this.changeState({ ...this.state, open: true });
}
removeCartItem(item: CartItemState) {
this.removeItemFromCartUseCase.execute(item.id)
.then(cart => this.changeState(this.mapToUpdatedState(cart)))
}
editQuantityCartItem(item: CartItemState, quantity: number) {
this.editQuantityOfCartItemUseCase.execute(item.id, quantity)
.then(cart => this.changeState(this.mapToUpdatedState(cart)))
}
addProductToCart(product: Product) {
this.addProductToCartUseCase.execute(product)
.then(cart => this.changeState(this.mapToUpdatedState(cart)))
}
private loadCart() {
this.getCartUseCase.execute()
.then(cart => this.changeState(this.mapToUpdatedState(cart)))
.catch(() => this.changeState({
kind: "ErrorCartState",
error: "An error has ocurred loading products",
open: this.state.open
}));
}
mapToUpdatedState(cart: Cart): CartState {
const formatOptions = { style: "currency", currency: "EUR" };
return {
kind: "UpdatedCartState",
open: this.state.open,
totalItems: cart.totalItems,
totalPrice: cart.totalPrice.toLocaleString("es-ES", formatOptions),
items: cart.items.map(cartItem => {
return {
id: cartItem.id,
image: cartItem.image,
title: cartItem.title,
price: cartItem.price.toLocaleString("es-ES", formatOptions),
quantity: cartItem.quantity
}
})
}
}
}
Aquí lo importante es que vamos a extender de Bloc y que cada vez que se produce un cambio de estado invocamos el método changeState de la clase base para que a través del las subcripciones, se notifique a los suscriptores del estado del cambio.
El resto es bastante similar al CartPresenter que ya teniamos, siendo ahora el BLoC el encargado de hablar con los casos de uso del dominio.
Añadiendo contexto
Ahora necesitamos añadir un contexto que contenga el CartBloc en el arbol de componentes, para poder tener acceso a este bloc desde cualquier hijo.
Lo añadiremos sobre la vista que sea el padre más cercano a todos los widgets que comparten el estado del carrito.
En nuestro ejemplo al tener un jerarquia sencilla, lo vamos a ubicar en app, pero en casos más complejos con jerarquias grandes no tendría por que ser así.
const [blocContext, useBloc] = createContext<CartBloc>();
export const useCartBloc = useBloc;
const App: React.FC = () => {
return (
<blocContext.Provider value={DependenciesProvider.provideCartBloc()}>
...
</blocContext.Provider>
);
};
export default App;
Refactorizando el componente App
Ya no necesitamos pasar el carrito o parte de sus propiedades ni las funciones manejadores de eventos que producen cambios en el carrito desde App hacia los hijos.
const [blocContext, useBloc] = createContext<CartBloc>();
export const useCartBloc = useBloc;
const App: React.FC = () => {
return (
<blocContext.Provider value={DependenciesProvider.provideCartBloc()}>
<MyAppBar />
<ProductList />
<CartDrawer />
</blocContext.Provider>
);
};
export default App;
Refactorizando componentes hijos
Ahora ya no es necesario pasar propiedades ni eventos a los hijos.
Simplemente en aquellos componentes hijos donde se necesite acceder bloc del carrito de la compra, utilizaremos el custom hook useCartBloc para recuperarlo.
Aquellos widgets que necesiten renderizar en base al estado del carrito vamos a utilizar el componente BlocBuilder que vimos en el apartado de infrastructura.
Este componente, como vimos, realiza el trabajo por nosotros de suscribirse al cambios de estado del bloc y renderizar cuando se produce un cambio en el stream.
Veamos por ejemplo CartContent:
const CartContent: React.FC = () => {
const classes = useStyles();
const bloc = useCartBloc();
...
return (
<BlocBuilder
bloc={bloc}
builder={(state: CartState) => {
switch (state.kind) {
case "LoadingCartState": {
return (
<div className={classes.infoContainer}>
<CircularProgress />
</div>
);
}
case "ErrorCartState": {
return (
<div className={classes.infoContainer}>
<Typography display="inline" variant="h5" component="h2">
{state.error}
</Typography>
</div>
);
}
case "UpdatedCartState": {
...
}
}
}}
/>
);
};
export default CartContent;
y aquellos componentes que necesiten invocar alguna acción del bloc, accederán al bloc de igual forma utilizando el custom hook useCartBloc.
Veamos por ejemplo ProductItem:
interface ProductListProps {
product: Product;
}
const ProductItem: React.FC<ProductListProps> = ({product}) => {
const classes = useStyles();
const bloc = useCartBloc();
return (
<Grid item xs={6} sm={4} md={3} lg={2}>
<Card className={classes.card}>
<CardMedia
className={classes.cardMedia}
image={product.image}
title="Image title"
/>
<CardContent className={classes.cardContent}>
<Typography className={classes.productTitle} gutterBottom variant="subtitle1">
{product.title}
</Typography>
<Typography variant="h6" className={classes.productPrice}>
{product.price.toLocaleString("es-ES", {
style: "currency",
currency: "EUR",
})}
</Typography>
</CardContent>
<CardActions className={classes.cardActions}>
<Button
size="small"
color="primary"
onClick={() => bloc.addProductToCart(product)}
>
Add to Cart
</Button>
</CardActions>
</Card>
</Grid>
);
};
export default ProductItem;
La mayor ventaja de utilizar bloc Pattern frente a levantar el estado es que ahora cuando se produce un cambio en el carrito de la compra, no se renderizan de nuevo App y todos sus hijos, solo aquellos suscritos a cambios de estado del carrito.
Código fuente
Os dejo el link al repositorio de GitHub donde podeís ver el código completo del ejemplo.
Recursos relacionados
- Clean Architecture: A Craftsman's Guide to Software Structure and Design
- Qué es el estado en frameworks declarativos.
- Gestión simple de estado en frameworks declarativos.
- Gestión simple de estado en ReactJS
- Gestión simple de estado en Flutter
- Introducción al patrón BLoc
- El Patrón Bloc en Clean Architecture
- Curso Clean Architecture
- El patrón BLoC junto a Clean Architecture en Flutter
- Alejándonos de ReactJs y VueJs en frontend usando Clean Architecture
Conclusiones
En este artículo hemos visto un ejemplo práctico de como encajan el patrón BloC y Clean Architecture aplicado a ReactJS.
Como hemos visto necesitamos crearnos una infraestructura básica si no utilizamos una libreria externa y ya no tenemos la necesidad de realizar perforación de propiedades.
Y vamos a tener un mejor rendimiento en nuestra app porque ahora cuando se produce un cambio en el carrito de la compra, no se renderizan de nuevo todos los componentes hijos de App, solo aquellos suscritos a cambios de estado del carrito.