Gestión simple de estado en ReactJS

En un anterior artículo sobre gestión simple de estado frameworks declarativos, introducimos conceptualmente la técnica de levantar el estado y perforación de propiedades como la técnica más básica de gestión de estado compartido.

En este artículo vamos a ver una solución del ejemplo en React.

Contexto

En el anterior artículo vimos un wireframe de una lista de productos y un carrito de la compra.

Este es el ejemplo que vamos a hacer en React, porque un carrito de la compra es un buen ejemplo de una estado compartido entre varios componentes.

Problema a resolver

Debemos poder añadir al carrito nuevos items desde el listado de productos.

En la barra de la aplicación vamos a poder ver el número de elementos que tenemos el carrito.

products

Al pulsar sobre el icono del carrito de la compra aparece un contenedor flotante con el contenido del carrito.

cart

Demo live del ejercicio.

Clean Architecture

Cuando desarrollamos aplicaciones SPA (Single Page Application), a nivel de responsabilidad este tipo de aplicaciones no difiere mucho de su versión mobile ya sea Android o iOS.

Tenemos una aplicación que contiene toda la lógica de la aplicación en el cliente SPA y se comunica con el servidor a través de una API.

Si además utilizamos TypeScript como lenguaje donde tenemos todo el poder de los tipos a nuestro alcance es perfectamente posible utilizar Clean Architecture o Hexagonal Architecture al igual que podemos hacer en Android o iOS.

Voy a utilizar Clean Architecture y por lo tanto vamos a tener una capa de dominio, una datos o infraestructura y otra de presentación donde el dominio estará aislado del resto de capas.

En la capa de presentación voy a utilizar una versión modificada de MVP (Model View Presenter) ya que me gusta más utilizar funciones y hooks, en lugar de clases, para crear los componentes y por lo tanto no tiene sentido tener un interfaz para la vista.

Dónde se gestiona el estado

Como vimos en anteriores artículos, los frameworks declarativos se basan en la creación de componentes inmutables y cuando su estado cambia se renderizan completamente de nuevo junto a sus hijos.

El reto de gestión de estado compartido entre componentes nos lo propone la renderización declarativa del framework o librería de interfaz de usuario y por lo tanto es en la capa de presentación donde se debe resolver.

La capa de dominio y datos no debería verse afectada por la estrategia que se decida para la gestión de estado entre componentes, ya que nos estaríamos acoplando al framework declarativo de presentación, en este caso ReactJS, indirectamente.

No voy a entrar en mucho detalle sobre como hacer Clean Architecture en ReactJS, seguramente escriba otro artículo sobre ello, pero si me parece importante destacar que la gestión de estado en ReactJS, debería ser ajena al dominio de la aplicación y este no debería verse contaminado por la estrategia que utilicemos.

Estructura de componentes

En nuestro ejemplo los componentes quedarían desglosados de esta manera:

componentes-gestion-simple-estado-react

En el diagrama se aprecia el levantamiento del estado al componente app y la perforación de props donde se incluyen también las funciones que se van a encargar de manejar las diferentes acciones.

Vamos al código

En el código hay dos features destacadas como es la carga del listado de productos y el carrito de la compra.

Vamos a ver algunos componentes desde la perspectiva del carrito de la compra y solo la capa de presentación que es donde se gestiona el estado, dejando a un lado dominio y datos.

Para este ejemplo he utilizado Material-UI como framework React.

Levantando el estado

Recordad que la estrategía que vamos a seguir en este artículo es levantar el estado y perforación de propiedades.

Por lo tanto app que es el componente superior más cercano a los componentes que deben acceder al estado del carrito y va a contener el estado del carrito.


interface AppProps {
  cartPresenter: CartPresenter;
}

const App: React.FC <AppProps>= ({cartPresenter}) => {
  const [cartState, setCartState] = React.useState<CartState>({
    open: false,
    cart: Cart.createEmpty()
  });

  React.useEffect(() => {
    cartPresenter.init(onCartChangeHandler);
  }, [cartPresenter]);
  
    const onCartChangeHandler = (cartState: CartState) => setCartState(cartState);
    
  ...

Fijaros que interactua con cartPresenter que es el que contiene la lógica de presentación en lo que se refiere al carrito y por lo tanto el que modifica su estado.

CartPresenter es el encargado de interactuar con la capa de dominio y cuando el estado del carrito cambia se lo comunica a la vista, en este caso, utilizando la función onCartChangeHandler que a su vez cambia el estado de App utilizando el hook useState.

Perforación de propiedades

Ya tenemos el estado levantado y ahora necesitamos pasarselo a los componentes hijos utilizando la perforación de propiedades. Pero no solo el estado, también las funciones que van a manejar cuando hay que mostrar el carrito, cuando hay que añadir un elemento al carrito, cuando hay que eliminarlo y cuando se modifica la cantidad de un elemento del carrito.

Estas funciones estan definidas en App y se pasan sus hijos jerárquicamente junto al estado hasta donde deben ser ejecutadas.


  ...
  
  const handleRemoveCartItem = (item: CartItem) => {
    cartPresenter.removeCartItem(item);
  };

  const handleEditQuantityCartItem = (item: CartItem, quantity: number) => {
    cartPresenter.editQuantityCartItem(item, quantity);
  };

  const handleAddProductToCart = (product: Product) => {
    cartPresenter.addProductToCart(product);
  };

  const handleDrawerOpen = () => {
    cartPresenter.openCart();
  };

  const handleDrawerClose = () => {
    cartPresenter.closeCart();
  };
    
  ...
  
  return (
    <div>
      <MyAppBar
        onShoppingCartHandler={handleDrawerOpen}
        totalCartItems={cartState.cart.totalItems}
      />
      <ProductList productsPresenter={productsPresenter} onAddProductToCart={handleAddProductToCart}/>
      <CartDrawer
        cart={cartState.cart}
        open={cartState.open}
        onClose={handleDrawerClose}
        onRemoveCartItem={handleRemoveCartItem}
        onEditQuantityCartItem={handleEditQuantityCartItem}
      />
    </div>
  );
};

export default App;

AppBar

El componente appBar recibe en las props la función a invocar cuando el carrito debe visualizarse y también el número total de elementos que hay en el carrito.

interface AppProps {
  totalCartItems: number;
  onShoppingCartHandler: () => void;
}

const MyAppBar: React.FC<AppProps> = ({
  totalCartItems,
  onShoppingCartHandler
}) => {
  const classes = useStyles();

  return (
    <AppBar position="static">
      <Toolbar className={classes.toolbar}>
        <img src={logo} width={150} alt="logo" />
        <IconButton color="inherit">
          <Badge badgeContent={totalCartItems} color="secondary">
            <ShoppingCartIcon onClick={onShoppingCartHandler} />
          </Badge>
        </IconButton>
      </Toolbar>
    </AppBar>
  );
};

export default MyAppBar;

ProductItem

El componente que representa los elementos del listado de productos participa en la modificación del estado del carrito al invocar la función onAddProductToCart, por lo tanto recibe la función en las props:

interface ProductListProps {
  product: Product;
  onAddProductToCart: (product: Product) => void;
}

const ProductItem: React.FC<ProductListProps> = ({
  product,
  onAddProductToCart
}) => {
  const classes = useStyles();

  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={() => onAddProductToCart(product)}
          >
            Add to Cart
          </Button>
        </CardActions>
      </Card>
    </Grid>
  );
};

export default ProductItem;

CartContentitem

Igualmente el componente que representa los elementos que ya existen en el carrito debe invocar la acción de modificar la cantidad de un elemento existente dentro del carrito y la acción de eliminar:

interface CartProps {
  key: Key;
  cartItem: CartItem;
  onRemoveCartItem: (item: CartItem) => void;
  onEditQuantityCartItem: (item: CartItem, quantity: number) => void;
}

const CartContentItem: React.FC<CartProps> = ({
  key,
  cartItem,
  onRemoveCartItem,
  onEditQuantityCartItem
}) => {
  const classes = useStyles();

  const handleQuantityChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    onEditQuantityCartItem(cartItem, +event.target.value);
  };

  return (
    <React.Fragment>
      <Paper className={classes.itemContainer}>
        <ListItem key={key}>
          <img
            width={80}
            className={classes.itemImage}
            src={cartItem.image}
            alt={cartItem.title}
          />
          <ListItemText
            primary={cartItem.title}
            secondary={
              <Box flexDirection="row" className={classes.secondContainer}>
                <TextField
                  id="standard-number"
                  label="Quantity"
                  type="number"
                  className={classes.quantityField}
                  InputLabelProps={{
                    shrink: true
                  }}
                  margin="none"
                  value={cartItem.quantity}
                  onChange={handleQuantityChange}
                />
                <Typography variant="body1">
                  {cartItem.price.toLocaleString("es-ES", {
                    style: "currency",
                    currency: "EUR"
                  })}
                </Typography>
              </Box>
            }
          />
          <ListItemSecondaryAction>
            <IconButton edge="end" aria-label="delete">
              <RemoveIcon onClick={() => onRemoveCartItem(cartItem)} />
            </IconButton>
          </ListItemSecondaryAction>
        </ListItem>
      </Paper>
    </React.Fragment>
  );
};

export default CartContentItem;

Limitaciones

Conviene recordar que esta estrategía de levantar el estado tiene sus limitaciones:

  • A medida que una aplicación crece puede ser un problema utilizar esta técnica por toda la aplicación porque cada cambio de estructura en los componentes supone reajustar el paso de propiedades y funciones que manejan eventos de padres a hijos en la parte de la jerarquía afectada.

  • Además en nuestro caso, con cada cambio de estado se reconstruye el componente app y todos sus hijos, es decir casi prácticamente toda la aplicación. Se reconstruyen componentes de forma innecesaria como el listado de productos, de forma que incluso en esta app tan pequeña se aprecia un refresco innecesario del listado de productos con cada cambio de estado del carrito.

Para una parte pequeña de la jerarquía de componentes de una aplicación pienso que esta opción es perfectamente válida.

Artículos Relacionados

Código fuente

Os dejo el el link al repositorio de GitHub donde podeís ver el código completo del ejemplo.

Conclusiones

En este artículo hemos visto como aplicar la estrategía de levantar estado y perforación de propiedades en una aplicación React.

También hemos visto que si utilizamos una arquitectura tipo Clean Architecture o Hexagonal Architecture, el dominio no debería verse contaminado por las decisiones de gestión de estado.

Y el dominio no debe verse afectado primero porque el reto de la gestión de estado no nos lo plantea el dominio de la aplicación sino el framework de presentación seleccionado al ser declarativo, en este caso React y segundo porque esto nos va a permitir tener una aplicación más escalable y testable.

En futuros artículos veremos como hacer este mismo ejemplo pero utilizando un framework declarativo de entorno mobile como Flutter y también este mismo ejemplo pero con estrategías de gestión del estado más complejas.