DEV Community

loading...

Developing a ui architecture

Jack
Typescript and React nerd. I have a soft spot for architecture and unit testing
・4 min read

So previously I discussed at length how my backend application is archtected. If you haven't read it already I'd strongly suggest you do before continuing, as this article very much continues on the same path.

So once again my frontend is based on hexaganoal architecture. It's very very similar to the pattern I explained previously, with a few small but important differences.

When we talk about frontend we always focus on component organisation, and this is important, of course; but it also just as important to think about organising the non-presentational aspects of your application. I often see people making fetch requests inside components. This might be quicker to throw together and push out, but as a project grows, it becomes an absolute chore to find where in your code you are fetching data or causing side effects.

Overall Structure:

src
│
└───ui
|
└───application
|   └───feature
|       |   useUsecaseHook.ts
|
└───core
|   |   feature.ts
|
└───infrastructure
|   └───feature
|       |   method.ts
|
└───domain
|   |   feature.ts
Enter fullscreen mode Exit fullscreen mode

The most obvious difference is the api layer has been replaced with a ui layer, for obvious reasons. I'll come back to the contents of the ui layer shortly.

Application

The other difference is that the application layer is actually now just a collection of react hooks. For some reason in a react application, this just makes a lot of sense. All of your usecases are going to be tied to hooks and state. The only problem with this approach is the application layer is coupled to react so you couldn't access any of the usecases outside of a react context. However, I decided this was a small architectural price to pay for convenience (given that I'll almost definitely never be using the application layer outside of react).

Core / Infrastructure / Domain

I won't go into these in much detail because they are literally the same as the same areas in the backend app.

The tl;dr: core is abstract interfaces, infrastructure is implementations of those interfaces (stuff that does side effects and "contacts the outside world"), and domain is pure business logic.

UI

So what's going on in the ui layer? It's nothing extraordinary, in fact it follows a pretty common pattern that's similar to atomic design:

ui
└───elements
|   |   ButtonComponent
|
└───modules
|   └───feature
|       |   CompositeComponent
|
└───pages
|   └───feature
|       | FeaturePageComponent
|
└───app
    | AppComponent
Enter fullscreen mode Exit fullscreen mode

elements

Elements are small self-contained components that have no application logic or knowledge. Things like buttons, grids, inputs, and so on.

I have maybe 2 exceptions to the rule here, which is an Image component that takes a partial src and computes the full url based on my app config. And an Upload input element that internally handles uploading a file to the server and just returns the resulting url. Should I make these dumber and less tied to the rest of the stack? Yes. Will I? Maybe eventually 👀

modules

A module is a group of elements that make up part of a page. For example, if you have a search page, you might have a module for the search input area, a module for the list area, and a module for the individual list item. A module can also be made up of other modules.

A module can have domain knowledge.

The important part to note here is that modules are all "dumb". A module will never fetch data or send data, it won't read cookies, it won't use the application layer. Anything "smart" is done by the parent pages.

What complicates this is that sometimes a module might render another module that relies on some smart stuff:

function Overview({ item, onAddToBasket, onViewMoreInfo }) {
  return (
    <ProductItem
      item={item}
      onAddToBasket={onAddToBasket}
      onViewMoreInfo={onViewMoreInfo}
    />
  );
}

function ProductItem({ item, onAddToBasket, onViewMoreInfo }) {
  return (
    <div>
      <span>{item.name}</span>
      <ProductActions
        item={item}
        onAddToBasket={onAddToBasket}
        onViewMoreInfo={onViewMoreInfo}
      />
    </div>
  );
}

function ProductActions({ item, onAddToBasket, onViewMoreInfo }) {
  return (
    <div>
      <Button onClick={onAddToBasket}>Add to basket</Button>
      <Button onClick={onViewMoreInfo}>More info</Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

If we want to provide the onAddToBasket prop to the ProductActions component, we have to pass the prop all the way down. Prop drilling is a frustrating and tedious part of react development. This is why we often just bypass the smart/dumb construct and stick the smart logic inside ProductActions instead, but this causes more issues as you start to lose track of where your smart behaviour comes from.

My solution is to actually pass elements as props, so you compose your dumb components and then pass them down instead:

function Overview({ item, children }) {
  return (
    <ProductItem item={item}>
      {children}
    </ProductItem>
  );
}

function ProductItem({ item, children }) {
  return (
    <div>
      <span>{item.name}</span>
      {children}
    </div>
  );
}

function ProductActions({ item, onAddToBasket, onViewMoreInfo }) {
  return (
    <div>
      <Button onClick={onAddToBasket}>Add to basket</Button>
      <Button onClick={onViewMoreInfo}>More info</Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And at the smart level (i.e. pages) then you can do:

<Overview item={item}>
  <ProductActions
    item={item}
    onAddToBasket={handleAddToBasket}
    onViewMoreInfo={handleViewMoreInfo}
  />
</Overview>
Enter fullscreen mode Exit fullscreen mode

This does get more complex when you have multiple components to compose, but I think it's better than mountains of prop drilling or smart components buried deep in the module layer.

pages

A page is what it says on the tin, it is the construct of an entire page or view. Pages serve three purposes: they put together multiple modules into a cohesive whole; they handle interacting with the application layer for fetching and mutating data; and they orchestrate the routing of the application.

// A single page, composing the view from multiple modules
function ProductPage() {
  const item = useFetchItem();
  const addToBasket = useAddToBasket();
  const viewMore = useViewMore();

  return (
    <Overview item={item}>
      <ProductActions
        item={item}
        onAddToBasket={addToBasket}
        onViewMoreInfo={viewMore}
      />
    </Overview>
  );
}

// A Page that stitches together other pages with routing
function ProductPages() {
  return (
    <Route path="/product/:id">
      <ProductPage/>
    </Route>
  );
}
Enter fullscreen mode Exit fullscreen mode

So there it is. Just like the backend - hexagonal architecture, separation of concerns, and dependency injection form the basis of the codebase. Is it perfect? No. Is it easy to maintain and follow? I think so. Is it for everyone? Probably not!

Discussion (0)