How to sync a JS class to a component's state in React?

Viewed 109

I am completing a technical challenge and came across a scenario I never had to deal with before.

I am asked to code up a shopping cart that has a UI that represents basic checkout data like order total, current items in cart, etc.

One of the requirements states I need to implement a Checkout class that can be instantiated:

const checkout = new Checkout();

And I should be able to obtain basic info from it like:

const total = checkout.total();

And add items to the cart through it:

checkout.add(product.id);

What makes this a tricky thing to solve is that I can't think of a clean "DRY" way of implementing it into the UI. This is mainly because any updates in the checkout class will not trigger any re-renders since it's not part of the state. I would usually use state variables for this.

I tried binding state variables to parameters in the checkout class like:

const [total, setTotal] = useState();
useEffect(()=>{
   setTotal(checkout.total)
}, [checkout.total])

But checkout.total is only the reference to the method, so it never changes so I do not get the binding I want.

Trying out other stuff I managed to put together a "solution" but I question whether it is a good pattern.

I basically pass a callback to the checkout class which is called whenever the cart is updated. The callback is a state variable's setter, so:

const [cart, setCart] = useState<string[]>(checkout.cart);
checkout.callback = setCart;

Then inside the add method:

add(productId) {
   // Some code...
   this.callback([...this.cart]);
}

What this grants is that the cart state variable is updated whenever the checkout class has changes in its parameters. So it fires a rerender on the Cart component and all of its children that have props being passed down. Therefore I get a synced UI.

The thing is I kind of don't need the cart variable other than for forcing re-renders. I can get the cart info directly from the checkout class which is what I do. But for it to be reflected in the UI I need some state variable to be updated. It could even be a counter, I only went for cart instead of a counter to make it more coherent I guess.

Am I overcomplicating things here? Is there a pattern I am missing that is used for this scenarios? How does one usually interact with an instantiated class and ensures the UI is somehow updated from changes to the class?

EDIT (adding missing info): The Checkout class needs to implement the following interface:

interface Checkout {
  // ...
  // Some non relevant properties methods
  // ...
  add(id: number): this;
}

So it is explicitly asked that the add method returns this (in order to allow function chaining).

5 Answers

mixing of patterns

Using OOP instances with methods that mutate internal state will prevent observation of a state change -

const a = new Checkout()
const b = a                     // b is *same* state
console.log(a.count)            // 0
a.add(item)
console.log(a.count)            // 1
console.log(a == b)             // true
console.log(a.count == b.count) // true

React is a functional-oriented pattern and uses complimentary ideas like immutability. Immutable object methods will create new data instead of mutating existing state -

const a = new Checkout() 
const b = a.add(item)           // b is *new* state
console.log(a.count)            // 0
console.log(b.count)            // 1
console.log(a == b)             // false
console.log(a.count == b.count) // false

In this way, a == b is false which effectively sends the signal to redraw this component. So we need a immutable Checkout class, where methods return new state instead of mutating existing state -

// Checkout.js

class Checkout {
  constructor(items = []) {
    this.items = items
  }
  add(item) {
    return new Checkout([...this.items, item]) // new state, no mutation
  }
  get count() {
    return this.items.length // computed state, no mutation
  }
  get total() {
    return this.items.reduce((t, i) => t + i.price, 0) // computed, no mutation
  }
}

export default Checkout

demo app

Let's make a quick app. You can click the and buttons to add items to the cart. The app will show the correct count and total as well as the individual items -

App component preview
preview

Now "syncing" the class to the component is just using ordinary React pattern. Use your class and methods directly in your componenets -

import Checkout from "./Checkout.js"
import Cart from "./Cart.js"

function App({ products = [] }) {
  const [checkout, setCheckout] = React.useState(new Checkout)
  const addItem = item => event =>
    setCheckout(checkout.add(item))
  return <div>
    {products.map(p =>
      <button key={p.name} onClick={addItem(p)}>{p.name}</button>
    )}
    <b>{checkout.count} items for {money(checkout.total)}</b>
    <Cart checkout={checkout} />
  </div>
}

const data =
  [{name: "", price: 5}, {name: "", price: 3}]

const money = f =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(f)

A simple Cart component uses JSON.stringify to quickly visualize each item -

// Cart.js

function Cart({ checkout }) {
  return <pre>{JSON.stringify(checkout, null, 2)}</pre>
}

export default Cart

Run the demo below to verify the result in your browser -

class Checkout {
  constructor(items = []) {
    this.items = items
  }
  add(item) {
    return new Checkout([...this.items, item])
  }
  get count() {
    return this.items.length
  }
  get total() {
    return this.items.reduce((t, i) => t + i.price, 0)
  }
}

function App({ products = [] }) {
  const [checkout, setCheckout] = React.useState(new Checkout)
  const addItem = item => event =>
    setCheckout(checkout.add(item))
  return <div>
    {products.map(p =>
      <button key={p.name} onClick={addItem(p)}>{p.name}</button>
    )}
    <b>{checkout.count} items for {money(checkout.total)}</b>
    <Cart checkout={checkout} />
  </div>
}

const money = f =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(f)
  
function Cart({ checkout }) {
  return <pre>{JSON.stringify(checkout, null, 2)}</pre>
}

const data = [{name: "", price: 5}, {name: "", price: 3}]

ReactDOM.render(<App products={data} />, document.body)
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js"></script>

Hmm, looks like you need to share the state. The first solution that came to my mind is just to use the Class component. You can use force rerender while you need and write more custom logic without useEffect hacks.

The second solution is more clear IMO. It uses an Observer pattern. You need to add a subscription to your Checkout class. So basically.

useEffect(() => {
 const subscription = (newState) => setState(newState)
 const instance = new Checkout()
 instance.subcribe(subscription)
 return instance.unsubcribe(subscription)
}, [setState])

Since setState is immutable, this hook will be run only once.

Your idea is correct, you need somehow to start re-render to sync state of checkout object and state of a component.

E.g. you may do it by context and force update (in case if you do not want to duplicate data in object and state):

const CheckoutContext = React.createContext();

const checkout = new Checkout();

const CheckoutProvider = ({ children }) => {
  // init force update, just to start re-render
  const [ignored, forceUpdate] = React.useReducer((x) => x + 1, 0);
  
  const add = (a) => {
    checkout.add(a);
    forceUpdate();
  };

  const total = checkout.total();

  const value = { add, total };

  return (
    <CheckoutContext.Provider value={value}>
      {children}
    </CheckoutContext.Provider>
  );
};


const Child = () => {
  const v = React.useContext(CheckoutContext);
  console.log(v.total);
  return <button onClick={() => v.add(100)}>Click</button>;
};

export default function App() {
  return (
    <div className="App">
      <CheckoutProvider>
        <Child />
      </CheckoutProvider>
    </div>
  );
}

You can make a Cart class that allows for observers to be notified when something important happens. To make it available for the react components, provide an instance of it with a context, and use a stateful hook to notify components by setting the state through the observer function.

Here we go: First, we need a Cart class that notifies observers when something happens

export class Cart {
  constructor() {
    this.products = [];
    this.subscribers = new Set();
  }
  subscribe = (notifyMe) => {
    this.subscribers.add(notifyMe);
  };
  unSubscribe = (notifyMe) => {
    this.subscribers.delete(notifyMe);
  };
  addToCart = (product) => {
    this.products = [...this.products, product];
    this.notify();
  };
  removeFromCart = (product) => {
    this.products = this.products.filter(product);
    this.notify();
  };
  notify = () => {
    this.subscribers.forEach((n) => n(this.products));
  };
}

We will expose this through the react tree with a context, so lets make one

const CartContext = React.createContext();

export const CartProvider = ({ children, cart }) => {
  return <CartContext.Provider value={cart}>{children}</CartContext.Provider>;
};

Now for the trick! A hook that will update its state using the carts observer pattern, thereby notifying the component that uses it.

export const useCart = () => {
  const cart = React.useContext(CartContext);
  const [content, r] = React.useState();
  React.useEffect(() => {
    const notify = (productsInCart) => r(productsInCart);
    cart.subscribe(notify);
    cart.notify();
    return () => cart.unSubscribe(notify);
  }, [cart, r]);
  return {
    addToCart: cart.addToCart,
    removeFromCart: cart.removeFromCart,
    content
  };
};

Note that it can be worth to update after subscribing.

Now we have our library set up, we can make some components. So here's where we instantiate the Cart class. We make a new Cart, and let the provider provide that instance

const cart = new Cart();

export default function App() {
  return (
    <div className="App">
      <CartProvider cart={cart}>
        <CartCounter />
        <h1>Welcome to the shop</h1>
        <h2>start putting stuff in the cart!</h2>
        <Catalog />
        <button
          onClick={() => {
            // this will still notify components
            cart.addToCart({ foo: "bar" });
          }}
        >
          add product by directly manipulating class instance
        </button>
      </CartProvider>
    </div>
  );
}

Here are the other components

const Catalog = () => {
  const getProducts = async () =>
    await fetch(
      "https://random-data-api.com/api/commerce/random_commerce?size=6"
    ).then((r) => r.json());
  const [products, setProducts] = React.useState();
  React.useEffect(() => {
    getProducts().then(setProducts);
  }, []);

  if (!products) {
    return null;
  }

  return (
    <ul
      style={{
        listStyle: "none",
        display: "grid",
        gridTemplateColumns: "50% 50%"
      }}
    >
      {products.map((product) => (
        <Item key={product.uid} product={product} />
      ))}
    </ul>
  );
};

const Item = ({ product }) => {
  const { addToCart } = useCart();
  const addProductToCart = () => addToCart(product);
  return (
    <li>
      <article
        style={{
          maxWidth: 200,
          border: "1px solid black",
          margin: 10,
          padding: 10
        }}
      >
        <h4>{product.product_name}</h4>
        <div>
          <div>$ {product.price}</div>
          <button onClick={addProductToCart}>add to cart</button>
        </div>
      </article>
    </li>
  );
};

const CartCounter = () => {
  const { content } = useCart();
  return <div>items in cart: {content?.length || 0}</div>;
};

This can be a pretty handy pattern, and can be taken pretty far (e.g. React Query works like this).

CodeSandbox link

I think it is perfectly reasonable to send a callback to the object and then call that callback when it is needed. If you don't want to add any unnecessary data, then don't:

add(productId) {
  // Some code...
  this.callback();
}

checkout.callback = () => {
  setTotal(checkout.total);
}
Related