Alejándonos de ReactJs y VueJs en frontend usando Clean Architecture

Una de las ventajas de utilizar Clean Architecture, entre otras, es la capacidad de desacoplar nuestra aplicación del mecanismo de entrega al usuario, es decir, del framework o librería de UI.

Esto en aplicaciones de largo recorrido nos permite poder adaptarnos en el futuro a los cambios que seguro se van a producir en librerías y frameworks.

En este artículo vamos a llevar al extremo Clean Architecture en frontend teniendo una aplicación con dos mecanismos de entrega: ReactJS y VueJs.

Vamos a tener el máximo de código posible reutilizado entre las dos implementaciones.

Esto será posible creando la lógica de dominio, de datos y de presentación alejada de ReactJs y VueJs.

¿Por qué alejarnos del framework?

He desarrollado en diferentes tecnologías aplicando Clean Architecture como .Net, Android, iOS, Flutter. Desde hace un tiempo estoy desarrollando también en frontend y escribiendo sobre ello.

Uno de lo mayores problemas a la hora de poder evolucionar una aplicación es el acoplamiento al framework de UI.

En el frontend poco a poco debido a las responsabilidades que han ido ganando las aplicaciones de este tipo con el tiempo, cada vez tiene más sentido desarrollar de una forma más estructurada y los problemas a resolver son muy similares a los que existen en otros frentes como backend o desarrollo mobile.

Existen frameworks como ReactJs y VueJs que nos hacen la vida más fácil para asumir esos retos en el frontend.

Una aplicación frontend a día de hoy es una aplicación independiente del backend en muchos casos y por lo tanto, necesita tener su propia Arquitectura.

Además, es importante que esta aquitectura nos ayude en los siguientes puntos:

  • Independencia de UI, frameworks, API rest y persistencia,base de datos o servicios de terceros.
  • Escalabilidad.
  • Testabilidad.

Esto quiere decir que si cambiamos la visión de tener una app ReactJS o VueJS a una aplicación frontend que utiliza ReactJS o VueJS para renderizar, esto nos hará la vida mucho más fácil en el futuro.

De esta forma por ejemplo, evolucionar tu aplicación ReactJS de utilizar clases como se hacia antes, a utilizar funciones y hooks como se hace ahora, es mucho más trivial. Lo mismo ocurre si pasas en VueJS de usar options API a composition API.

Es más trivial porque solo utilizas el framework para lo estríctamente necesario para renderizar y así no lo sobre utilizas, mantiéndolo alejado de cualquier tipo de lógica ya sea lógica de dominio, datos o de presentación.

Los frameworks evolucionan y eso no lo puedes controlar, pero lo que si puedes controlar es el acoplamiento que tienes con ellos y como te afectan sus cambios.

Pero en este caso vamos a ir más allá de como adaptarnos a cambios que pueden suceder en un framework y vamos a ver la cantidad de código que podría no cambiar modificando ReactJS por VueJS si utilizamos Clean Architecture y separamos responsabilidades.

Este es el dibujo que se tiene en mente si estas acostumbrado a desarrollar usando Clean Architecture.

Si no tienes claros los conceptos de Clean Architecture, te recomiendo que te leas este artículo: ¿Por qué utilizo Clean Architecture en mis proyectos?.

La parte más importante es la regla de dependencia, así que si no sabes de que estoy hablando, te recomiendo que te leas el artículo.

El ejemplo que vamos a ver esta basado en el que vimos en este artículo: El patrón BLoC junto a Clean Architecture en reactJS

Nuestro escenario

Es un carrito de la compra con suficiente funcionalidad para parecerse a un ejemplo real. Vamos a tener estado global, estado no global y simularemos atacar un servicio remoto.

shopping-cart-react-vue

Arquitectura

A nivel de estructura de proyecto utilizaremos un monorepo utilizando yarn workspaces, de esta forma podemos dividir el proyecto en módulos o paquetes compartiendo código entre ellos.

Tenemos varios paquetes:

  • Core: en este paquete vamos a tener todo el código compartido entre la aplicación renderizada con ReactJS y la renderizada con VueJs.
  • React: en este paquete se encuentra la version react de la app.
  • Vue: en este paquete se encuentra la version vue de la app.

¿Qué código se reutiliza?

Vamos a reutilizar todo el código que debemos tener desacoplado del framework de UI, ya que al ser diferentes versiones de una misma app tiene sentido que este código sea compartido y no escrito nos veces.

Este es un ejercicio de demostración del potencial que tiene Clean Architecture pero este desacoplamiento del framwork de UI, es necesario incluso cuando desarrollamos una app real.

Utilizar el framework de UI para lo estríctamente necesario hace que nos podamos adaptar mejor a cambios de futuras versiones del framework.

Esto es debido a que el código que contiene la lógica de la aplicación, que es la parte más importante, que menos cambia con el tiempo, y que es el código potencialmente a compartir entre dos versiones de la misma app como en este ejemplo, esta desacoplado sin depender del framework de UI.

En clean architecture la capa de dominio es donde se encuentra la lógica de negocio empresarial y negocio de la aplicación.

La capa de datos es donde nos comunicamos con la persistencia ya sea una base de datos o un servicio web.

La lógica de presentación es aquella que decide que datos se muestran, si algo debe estar visible o no, si se debe mostrar al usuario que estamos cargando datos o si debe mostrarse un error. Es donde se gestiona el estado de los componentes.

Cada una de estas 3 partes, contiene lógica que debemos desacoplar y se encuentra en el paquete core.

frontend-clean-architecture-paquetes

Capa de Dominio

La capa de dominio es donde se encuentra la lógica de negocio empresarial y negocio de la aplicación.

Casos de uso

Un caso de uso es una intención, contiene lógica de negocio de la aplicación, son acciones y en este ejemplo tenemos los siguientes:

  • GetProductsUseCase
  • GetCartUseCase
  • AddProductToCartUseCase
  • EditQuantityOfCartItemUseCase
  • RemoveItemFromCartUseCase

Veamos el ejemplo de GetProductsUseCase

export class GetProductsUseCase {
    private productRepository: ProductRepository;

    constructor(productRepository: ProductRepository) {
        this.productRepository = productRepository;
    }

    execute(filter: string): Promise<Either<DataError, Product[]>> {
        return this.productRepository.get(filter);
    }
}

Este caso de uso es sencillo porque consiste en una llamada simple a la capa de datos, en otros contextos donde por ejemplo al dar de alta un producto, tengamos que validar que no existe ya uno con el mismo SKU, existiría más lógica.

Los casos de uso devuelven un tipo Either, si no tienes claro en que consiste te recomiendo que leas este artículo: Either en TypeScript y también este Either asíncrono con promesas en TypesSript.

De esta forma, la gestion de errores no se realiza utilizando catch de las promises, sino que el propio objeto resultado de la promise te indica si el resultado es satisfacctorio o no.

El uso de Either frente al try catch clásico tiene varios beneficios:

  • El flujo de ejecución es más simple de seguir sin saltos entre llamadores cuando se produce un error.
  • Explícitamente se indica que algo puede fallar.
  • Explícitamente se indican los errores que pueden ocurrir.
  • Haciendo uso de switch exhaustivos, si añades más errores en un futuro, TypeScript te avisará de donde no has tenido en cuenta este nuevo error.

El tipo para los errores es el siguiente:

export interface UnexpectedError {
    kind: "UnexpectedError";
    message: Error;
}

export type DataError = UnexpectedError;

Potencialmente en un futuro podría evolucionar a algo así:

export interface ApiError {
    kind: "ApiError";
    error: string;
    statusCode: number;
    message: string;
}

export interface UnexpectedError {
    kind: "UnexpectedError";
    message: Error;
}

export interface Unauthorized {
    kind: "Unauthorized";
}

export interface NotFound {
    kind: "NotFound";
}

export type DataError = ApiError | UnexpectedError | Unauthorized;

Y en la capa de presentación, si estoy usando switch exaustivo me avisaría Typescript, debería añadir más casos para cada nuevo error.

entidades

Las entidades contienen la lógica de negocio empresarial.

Veamos por ejemplo Cart:

type TotalPrice = number;
type TotalItems = number;

export class Cart {
    items: readonly CartItem[];
    readonly totalPrice: TotalPrice;
    readonly totalItems: TotalItems;

    constructor(items: CartItem[]) {
        this.items = items;
        this.totalPrice = this.calculateTotalPrice(items);
        this.totalItems = this.calculateTotalItems(items);
    }

    static createEmpty(): Cart {
        return new Cart([]);
    }

    addItem(item: CartItem): Cart {
        const existedItem = this.items.find(i => i.id === item.id);

        if (existedItem) {
            const newItems = this.items.map(oldItem => {
                if (oldItem.id === item.id) {
                    return { ...oldItem, quantity: oldItem.quantity + item.quantity };
                } else {
                    return oldItem;
                }
            });

            return new Cart(newItems);
        } else {
            const newItems = [...this.items, item];

            return new Cart(newItems);
        }
    }

    removeItem(itemId: string): Cart {
        const newItems = this.items.filter(i => i.id !== itemId);

        return new Cart(newItems);
    }

    editItem(itemId: string, quantity: number): Cart {
        const newItems = this.items.map(oldItem => {
            if (oldItem.id === itemId) {
                return { ...oldItem, quantity: quantity };
            } else {
                return oldItem;
            }
        });

        return new Cart(newItems);
    }

    private calculateTotalPrice(items: CartItem[]): TotalPrice {
        return +items
            .reduce((accumulator, item) => accumulator + item.quantity * item.price, 0)
            .toFixed(2);
    }

    private calculateTotalItems(items: CartItem[]): TotalItems {
        return +items.reduce((accumulator, item) => accumulator + item.quantity, 0);
    }
}

En este ejemplo las entidades son simples, con propiedades de tipos primitivos, pero un ejemplo real donde existieran validaciones podríamos tener Entidades y Value Objects definos como clases y con factory methods donde se realiza la validación y usamos Either para devolver los errores o el resultado.

boundaries

Los boundaries son las abstracciones de los adaptadores, por ejemplo en Hexagonal Architecture se llaman ports. Se definen en la capa de los casos de uso en dominio e indíca como nos vamos a comunicar con los adaptadores.

Por ejemplo para comunicarnos con la capa de datos, usamos el patrón repositorio.

export interface ProductRepository {
    get(filter: string): Promise<Either<DataError, Product[]>>;
}

Capa de Datos

La capa de datos es donde se encuentran los adaptadores que se encargan de transformar las información entre el dominio y sistemas externos.

Sistemas externos puede ser un servicio web, una base de datos etc..

En este ejemplo sencillo, estoy usando las mismas entidades que representan al producto, carrito de la compra y item dentro del carrito entre las capas de presentación, datos y dominio.

En aplicaciones reales es habitual llegar a tener una estructura de datos diferente para cada capa o incluso tener Data Transfer Objects (DTOs) para pasar la información entre capas.

En este ejemplo, tenemos repositorios que devuelven datos almacenados memoria.

const products = [
  ...
];

export class ProductInMemoryRepository implements ProductRepository {
    get(filter: string): Promise<Either<DataError, Product[]>> {
        return new Promise((resolve, _reject) => {
            setTimeout(() => {
                try {
                    if (filter) {
                        const filteredProducts = products.filter((p: Product) => {
                            return p.title.toLowerCase().includes(filter.toLowerCase());
                        });

                        resolve(Either.right(filteredProducts));
                    } else {
                        resolve(Either.right(products));
                    }
                } catch (error) {
                    resolve(Either.left(error));
                }
            }, 100);
        });
    }
}

Lo importante es entender que el repositorio es un adaptador y que su abstracción o puerto esta definido en dominio, invirtiendo así la dirección de dependencia tradicional.

Inversion de dependencia

Esta es la parte más importante de Clean Architecture, el dominio no debe tener ninguna dependencia hacia capas exteriores, de esta forma esta desacoplado y es mas fácil reemplazar un adaptador por otro en un futuro o incluso con objetivos de testing.

De esta forma si sustituimos la implementación del adaptador por uno que ataque a un servicio web, el dominio no se ve afectado y por lo tanto, estamos ocultando detalles de implementación.

Capa de Pesentación - Adaptadores

Los adaptadores de la capa de presentación es el último eslabón reutilizable de nuestro paquete core, y es donde enganchamos la capa de UI React o Vue.

Esta capa es reutilizable también entre la dos versiones de la app, son intermediarios entre los componentes de UI y la capa de dominio.

Contienen la lógica de presentación, decidiendo qué información se muestra, qué debe estar visible etc...

La gestión de estado, la realiza esta capa y no depende ni de React ni de Vue.

Existen diferentes patrones de presentación que podemos utilizar, yo en este caso estoy utilizando el Patrón BLoC porque encaja muy bien con frameworks declarativos como React y Vue.

Patrón BloC

Si quieres profundizar en el patrón BLoC, te recomiendo que leas este artículo: Intrucción al patrón BLoC.

Como comenté en ese artículo, cuando utilizas BLoC con Clean Architecture, tiene más sentido llamarlos PLoC, Presentation Logic Component. Así que en este ejemplo aparecen nombrados de esta forma.

Veamos el ejemplo de carrito de la compra:

export class CartPloc extends Ploc<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,
                };
            }),
        };
    }
}

La clase base de todos los PLoCs, es la encargada de almacenar el estado y notificar cuando cambía.

type Subscription<S> = (state: S) => void;

export abstract class Ploc<S> {
    private internalState: S;
    private listeners: Subscription<S>[] = [];

    constructor(initalState: S) {
        this.internalState = initalState;
    }

    public get state(): S {
        return this.internalState;
    }

    changeState(state: S) {
        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);
        }
    }
}

Toda la información que necesita el componente de UI debe ser interpretada desde el estado, elementos a renderizar en una tabla o lista, pero también si algo debe estar visible o no, como el carrito de la compra, el loading o un error a mostrar.

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,
};

En este caso mediante union types de typescript podemos de una forma más segura y funcional modelar nuestro estado usando tipos de datos algebraicos sum.

Esta forma de modelar es menos propensa a errores porque indicas que una forma muy clara que estado tiene 3 posibilidades principales:

  • Cargando información
  • Se ha producido un error
  • Datos cargados

Capa de Presentacion - UI

En esta capa es donde se encuentran los componentes y todo lo relacionado con react o Vue como componentes, hooks, aplication etc..

Los componentes son muy sencillos y ligeros porque están liberados de gestionar cualquier tipo de lógica o gestión de estado, esto es responsabilidad de cada una de las capas en el paquete core.

React App

En react vamos a tener los componentes que renderizan nuestro listado de productos, el app bar con el número de productos en el carrito y el carrito de productos renderizado como un Sidebar.

Veamos el ejemplo del componente que renderiza el contenido del carrito.

import React from "react";
import { makeStyles, Theme } from "@material-ui/core/styles";
import { List, Divider, Box, Typography, CircularProgress } from "@material-ui/core";
import CartContentItem from "./CartContentItem";
import { CartItemState } from "@frontend-clean-architecture/core";
import { useCartPloc } from "../app/App";
import { usePlocState } from "../common/usePlocState";

const useStyles = makeStyles((theme: Theme) => ({
    totalPriceContainer: {
        display: "flex",
        alignItems: "center",
        padding: theme.spacing(1, 0),
        justifyContent: "space-around",
    },
    itemsContainer: {
        display: "flex",
        alignItems: "center",
        padding: theme.spacing(1, 0),
        justifyContent: "space-around",
        minHeight: 150,
    },
    itemsList: {
        overflow: "scroll",
    },
    infoContainer: {
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        height: "100vh",
    },
}));

const CartContent: React.FC = () => {
    const classes = useStyles();
    const ploc = useCartPloc();
    const state = usePlocState(ploc);

    const cartItems = (items: CartItemState[]) => (
        <List className={classes.itemsList}>
            {items.map((item, index) => (
                <CartContentItem key={index} cartItem={item} />
            ))}
        </List>
    );

    const emptyCartItems = () => (
        <React.Fragment>
            <Typography variant="h6" component="h2">
                Empty Cart :(
            </Typography>
        </React.Fragment>
    );

    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": {
            return (
                <React.Fragment>
                    <Box flexDirection="column" className={classes.itemsContainer}>
                        {state.items.length > 0 ? cartItems(state.items) : emptyCartItems()}
                    </Box>
                    <Divider />
                    <Box flexDirection="row" className={classes.totalPriceContainer}>
                        <Typography variant="h6" component="h2">
                            Total Price
                        </Typography>
                        <Typography variant="h6" component="h2">
                            {state.totalPrice}
                        </Typography>
                    </Box>
                </React.Fragment>
            );
        }
    }
};

export default CartContent;

Hooks

¿Usando Clean Architecture no se usan hooks?, si que se usan pero para lo estríctamente necesario.

No se va a gestionar el estado con hooks, los side effects no se disparan desde hooks esto es responsabilidad de los PloCs en el paquete core.

Pero si los vamos a utilizar para almacenar el estado final del componente que nos devuelve su PloC y los utilizaremos para compartir contexto entre componentes o para reaccionar al cambio de estado que nos devuelve el PloC.

Veamos como esta definido el hook usePLocState que usabamos en el componente:

export function usePlocState<S>(ploc: Ploc<S>) {
    const [state, setState] = useState(ploc.state);

    useEffect(() => {
        const stateSubscription = (state: S) => {
            setState(state);
        };

        ploc.subscribe(stateSubscription);

        return () => ploc.unsubscribe(stateSubscription);
    }, [ploc]);

    return state;
}

Este custom hook es el encargado de suscribirse a los cambios de estado del PloC y almacenar el estado final.

Vue app

En vue también vamos a tener los mismos componentes que en la versión de React.

Ahora veamos el componente que renderiza el contenido del carrito de la compra en la versión de Vue:

<template>
    <div id="info-container" v-if="state.kind === 'LoadingCartState'">
        <ProgressSpinner />
    </div>
    <div id="info-container" v-if="state.kind === 'ErrorCartState'">Error</div>
    <div id="items-container" v-if="state.kind === 'UpdatedCartState'">
        <div v-if="state.items.length > 0" style="overflow: scroll">
            <div v-for="item in state.items" v-bind:key="item.id">
                <CartContenttItem v-bind="item" />
            </div>
        </div>
        <h2 v-if="state.items.length === 0">Empty Cart :(</h2>
    </div>
    <Divider />
    <div id="total-price-container">
        <h3>Total Price</h3>
        <h3>{{ state.totalPrice }}</h3>
    </div>
</template>

<script lang="ts">
import { defineComponent, inject } from "vue";
import { CartPloc } from "@frontend-clean-architecture/core";
import { usePlocState } from "../common/usePlocState";
import CartContenttItem from "./CartContenttItem.vue";

export default defineComponent({
    components: {
        CartContenttItem,
    },
    setup() {
        const ploc = inject<CartPloc>("cartPloc") as CartPloc;
        const state = usePlocState(ploc);

        return { state };
    },
});
</script>

<style scoped>
#info-container {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100vh;
}
#items-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 150px;
    justify-content: space-around;
}
#total-price-container {
    display: flex;
    align-items: center;
    padding: 8px 0px;
    justify-content: space-around;
}
</style>

Como veis se parece bastante a la versión de React usando composition API.

Composition Api

En la versión de Vue también vamos a tener hooks, como por ejemplo el que gestiona la suscripción a cambios en el estado del PLoC:

import { Ploc } from "@frontend-clean-architecture/core";

import { DeepReadonly, onMounted, onUnmounted, readonly, Ref, ref } from "vue";

export function usePlocState<S>(ploc: Ploc<S>): DeepReadonly<Ref<S>> {
    const state = ref(ploc.state) as Ref<S>;

    const stateSubscription = (newState: S) => {
        state.value = newState;
    };

    onMounted(() => {
        ploc.subscribe(stateSubscription);
    });

    onUnmounted(() => {
        ploc.unsubscribe(stateSubscription);
    });

    return readonly(state);
}

Injección de dependencias

Desde la app React y Vue tenemos que para cada componente crearnos o reutilizar la estructura PloC: casos de uso y repositorios.

Si estos conceptos estaban definidos en el paquete Core, la parte encargada de su creación puede estar en el paquete core también.

En esta ocasión estoy utilizando el patrón Service Locator de forma estática:

function provideProductsPloc(): ProductsPloc {
    const productRepository = new ProductInMemoryRepository();
    const getProductsUseCase = new GetProductsUseCase(productRepository);
    const productsPloc = new ProductsPloc(getProductsUseCase);

    return productsPloc;
}

function provideCartPloc(): CartPloc {
    const cartRepository = new CartInMemoryRepository();
    const getCartUseCase = new GetCartUseCase(cartRepository);
    const addProductToCartUseCase = new AddProductToCartUseCase(cartRepository);
    const removeItemFromCartUseCase = new RemoveItemFromCartUseCase(cartRepository);
    const editQuantityOfCartItemUseCase = new EditQuantityOfCartItemUseCase(cartRepository);
    const cartPloc = new CartPloc(
        getCartUseCase,
        addProductToCartUseCase,
        removeItemFromCartUseCase,
        editQuantityOfCartItemUseCase
    );

    return cartPloc;
}

export const dependenciesLocator = {
    provideProductsPloc,
    provideCartPloc,
};

También podríamos utilizar un Service Locator dinámico junto con Composition Root o una librería de inyección dependencias.

En la app React existe estado global que debe ser compartido, es el carrito de la compra. Por lo tanto CartPloc, que es quien gestiona este estado, debe ser compartido y accesible por todos los componentes.

React

En React resolvemos esto utilizando createContext y un custom hooks usando useContext.

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;
}


const [blocContext, usePloc] = createContext<CartPloc>();

export const useCartPloc = usePloc;

const App: React.FC = () => {
    return (
        <blocContext.Provider value={dependenciesLocator.provideCartPloc()}>
            <MyAppBar />
            <ProductList />
            <CartDrawer />
        </blocContext.Provider>
    );
};

export default App;

Utilizando el custom useCartPloc tenemos acceso desde cualquier componente a este PloC y su estado.

Vue

En Vue resolvemos esto utilizando la característica provide.

<template>
    <div id="app">
        <MyAppBar />
        <ProductList searchTerm="Element" />
        <CartSidebar />
    </div>
</template>

<script lang="ts">
import { dependenciesLocator } from "@frontend-clean-architecture/core";
import { defineComponent } from "vue";
import MyAppBar from "./appbar/MyAppBar.vue";
import ProductList from "./products/ProductList.vue";
import CartSidebar from "./cart/CartSidebar.vue";

export default defineComponent({
    name: "App",
    components: {
        ProductList,
        MyAppBar,
        CartSidebar,
    },
    provide: {
        cartPloc: dependenciesLocator.provideCartPloc(),
    },
});
</script>

Posteriormente desde cualquier componente usando const cartPloc = inject<CartPloc>("cartPloc") as CartPloc; tenemos acceso al PLoC y su estado.

Código fuente

El código fuente lo puedes encontrar aquí: frontend-clean-architecture

Artículos Relacionados

Conclusiones

En este artículo hemos visto una implementación de Clean Architecture en frontend.

Tenemos una versión de la app React y otra Vue reutilizando el máximo código posible entre las dos y ubicándolo en un paquete core.

Con este ejercicio de tener un paquete core con toda la lógica desacoplada del framework podemos apreciar la potencia que nos puede ofrecer Clean Architecture en el frontend.

Organizar el proyecto como monorepo y tener un paquete core ha sido necesario para este ejemplo pero no es algo necesario al desarrollar una app ya sea React o Vue.

Sin embargo es un ejercicio interesante para forzarte a desacoplarte del framework de UI ya que a veces puede resultar dificil ver que te estas acoplando, sobre todo al principio.