Architecting SaaS Frontends: Designing for Longevity and Scale

In the vast JavaScript ecosystem of frontend development, where new frameworks and libraries seem to arise every day, developers often find themselves in a paradox of choice. While many developers are quick to jump into the framework wars, the truth is that building maintainable and scalable applications goes deeper as it demands a good understanding of software architecture concepts.

It's Not Just About The Framework

At Junifia, while we recognize the importance of choosing a framework, we believe other aspects matter even more. We've experienced using great frameworks like React, Vue, Angular, etc., when building SaaS for our clients. But at the end of the day, these frameworks are just tools. They all have the same purpose and they all allow us to achieve the same thing: building incredible applications. So, any framework can be the right choice if it aligns with your project's requirements. The decision may simply rest on the tool you love the most, are comfortable with, or have good experience with.

Performance alone shouldn't be a deciding factor. Typically, one framework doesn't offer a drastic advantage over another in terms of performance. In real-world applications, the causes of performance lags come from network interactions and data management. It's more beneficial to concentrate on optimizing these areas rather than getting caught up in small performance variances between frameworks. In essence, thoughtful application design should take precedence over framework selection.

Concepts over Technology

So, successful applications focus on a strong foundation of architectural principles. But what are those concepts ? At Junifia, we have a few key concepts we keep in mind when it comes to building maintainable and scalable frontend applications:

  • Reducing the cost of change

    The main challenge of software development is adapting to change. Whether it's adding new features, fixing bugs, or responding to user feedback, changes are inevitable. The way we structure our applications plays a significant role in how efficiently we can accommodate these changes. To help us achieve this, we adhere to the SOLID principles.

  • Embrace good architecture

    Hexagonal or clean architecture, often associated with backend applications, are just as applicable to frontend applications. They encourage separation of concerns and isolation of external dependencies, making applications more maintainable and scalable. A key idea behind these architectures is to protect our business logic from external changes.

  • Presentational vs. Container components 

    While this concept has evolved over time, especially with the rise of hooks in React, the essence remains: separate UI components (how things look) from logic components (how things work).

Demonstration

Let's now illustrate the implementation of these concepts in a simple example. We will create a classic To-do list application. It will allow us to add, toggle and delete todo items. The example will be in React, but the concepts are applicable to any framework.

The TodoItem component

The first component we will create is the TodoItem component. This component will be responsible for displaying a single todo item. It will be a presentational component. This means it would only be responsible for displaying the todo item, but not for managing its state. A general rule is that components should not be aware where, or in which context, they are being used. So, let's see how this looks in code:

interface TodoItemProps {
  title: string;
  completed: boolean;
  onToggle: () => void;
  onDelete: () => void;
}

function TodoItem({ title, completed, onToggle, onDelete }: TodoItemProps) {
  return (
    <div className="todo-container">
      <div className="title-container">
        <Checkbox checked={completed} onChange={onToggle} />
        <span className={completed ? "title-completed" : "title-not-completed"}>
          {title}
        </span>
      </div>
      <button onClick={onDelete} className="delete-button">
        Delete
      </button>
    </div>
  );
}

export default TodoItem;

This component is designed to act as a clear interface, dictating through its props exactly how it should be utilized. It requires specific inputs — a title and a status indicating whether the item is completed. Additionally, it is prepared to respond to certain actions, specifically onToggle and onDelete. But, it remains agnostic to the logic behind these operations, it doesn’t know how to do them. This behaviour is given to him, what makes it highly reusable. This is the essence of a presentational component. Any visual tweaks needed for a todo item are isolated to this single location, safeguarding the functionality while altering only its appearance.

Rule #1: Components should not be aware where, or in which context, they are being used.

The TodoList component

Next, we will create the TodoList component. This will also a be a simple presentational component only responsible for displaying a list of todo items, and not managing their state:

interface TodoListProps {
  todos: Todo[];
  onToggle: (todo: Todo) => void;
  onDelete: (id: string) => void;
}

function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <TodoItem
            title={todo.title}
            completed={todo.completed}
            onToggle={() => onToggle(todo)}
            onDelete={() => onDelete(todo.id)}
          />
        </li>
      ))}
    </ul>
  );
}

export default TodoList;

The TodoPage component

Then, we will have the TodoPage component, which is a top level component. Because of this, pages are naturally container components. They are responsible for providing the data and the logic to the presentational components they embed but, without actually defining the logic themselves. Let's see how this looks in code:

function TodoPage() {
  const { todos, isLoading, error, addTodo, toggleTodo, deleteTodo } =
    useTodos();

  if (isLoading) return <div>Loading...</div>;

  if (error) return <div>Something went wrong</div>;

  return (
    <div className="container mx-auto p-12">
      <AddTodoForm onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
    </div>
  );
}

export default TodoPage;

As you can see, there's a new element introduced here. The page retrieves data and logic by invoking the useTodos function, which it then passes to its children. However, the page doesn't contain the logic itself. Instead, the logic resides within the functions returned by useTodos. In React, such functions are defined as hooks. We will see what is this hook in the next section. For the moment, understand that the page sources state and the functions to manage that state, but doesn't define the logic itself. That’s the role of a container component.

So, a rule to remember when creating container components is that they shouldn't perform the logic themselves; they should delegate it. In React, we can use hooks to achieve this separation.

Rule #2: Container components should source state and logic, but shouldn't perform the logic themselves; they should delegate it.

Custom hooks

Custom hooks are a great way to implement state management in React. They are merely functions and they should be used to encapsulate stateful logic and side effects to keep your code clean and decoupled. So here is specific rule to React: lean heavily into custom hooks for state management.

Now, let’s take a look at the useTodos hook, which we have seen in the previous section:

function useTodos() {
  const { todoRepository } = useTodoRepository();
  const { todos, isLoading, error } = useGetTodos(todoRepository);
  const { addTodo } = useAddTodo(todoRepository);
  const { toggleTodo } = useToggleTodo(todoRepository);
  const { deleteTodo } = useDeleteTodo(todoRepository);
  return { todos, isLoading, error, addTodo, toggleTodo, deleteTodo };
}

export default useTodos;
```

What stands out here is that useTodos is a composite hook, leveraging multiple other hooks to consolidate its logic. Such decomposition not only ensures responsibilities remain distinct but also improves code readability. A quick note on useTodoRepository: don't pay too much attention on it for now, we will come back to it later (but if you are curious, it is just returning simple abstraction over the data source).

Before going further, let's just mention that we will use React Query for data fetching. React Query is a great library that simplifies data fetching and caching. It is available in other frameworks as well. It is a library we recommend to use.

Now, here is the useGetTodos hook:

function useGetTodos(todoRepository: TodoRepository) {
  const {
    data: todos = [],
    isLoading,
    error,
  } = useQuery({ queryKey: ["todos"], queryFn: () => todoRepository.getAll() });

  return { todos, isLoading, error };
}

export default useGetTodos;


It's not important to understand perfectly how it works, but we are essentially using the useQuery hook from React Query to fetch the todos from a data source (like a REST API) and return them. The queryFn parameter is simply a function implementing this data retrieval. In React applications, it's common to find service files that contain such functions dedicated to fetching data from various sources, like this:

export const getTodos = async () => {
  const endpoint = "https://jsonplaceholder.typicode.com/todos";
  const response = await fetch(endpoint);
  const todos = await response.json();
  return todos;
};

So, you'll see getTodos being used as the second argument of the useQuery hook: useQuery({ queryKey: ["todos"], queryFn: getTodos}). This is a good practice, and works for simple applications. But we will not use this approach here.

Rule #3 (React Specific): lean heavily into custom hooks for state management.

The repository pattern

For this demonstration, we went a step further by implementing the repository pattern. Just know that it is for the purpose of explaining architectural concepts, and that it is overdesign for a simple application like this as it adds complexity. But it is a good practice for more complex applications. Here is how the TodoRepository interface looks like:

export interface TodoRepository {
  getAll(): Promise<Todo[]>;
  create(title: string): Promise<Todo>;
  update(todo: Todo): Promise<Todo>;
  delete(id: string): Promise<void>;
}

It is simply an abstraction of a data source for the todos. So the idea here is that we want to protect our application from the data source. In other words, we want to be able to easily change the data source without impacting the rest of the application. For example, imagine you have implemented all data fetching from a REST API, and, at some point, you want to switch to a GraphQL API. Or imagine your requirements change and your application or parts of your application need to work offline? You'll then have to change some code that can cause ripple effects if you haven't designed your application properly.

So, in this example, we have written two implementations of this interface. One for local storage and one for a simple REST API. So, for now, our application is storing and fetching todos form the browser local storage. But, if instead, we want to use a REST API, we just have to change one line of code, in the useTodoRepository hook that returns a TodoRepository:

function useTodoRepository() {
  const todoRepository: TodoRepository = new LocalStorageTodoRepository();
  return { todoRepository };
}

export default useTodoRepository;

Thus, we're applying two SOLID principles here. The Dependency Inversion Principle, because we can inject our different implementations of the abstraction in our hooks (as parameters) and The Open-Closed principle because we can extend our application by adding a new data source without changing existing code.

The repository pattern can also be useful if the backend or some of its endpoints are not ready yet. You can start implementing the frontend with a local storage implementation, and then switch to the REST API implementation when it is ready. It can also be useful for testing purposes as you can easily mock the data source.

Wrapping it up

To wrap it up, we have seen how to design maintainable and scalable frontend applications by applying some architectural concepts like dependency inversion and single responsibility principle. We also have seen how to reduce the cost of change by decoupling logic and state management from UI concerns by using custom hooks and separating presentational components from container components.

Get in Touch

We hope you enjoyed this article and that it will help you in your future projects. Don't hesitate to book a meeting with us if you want consulting for your SaaS project. We would be happy to discuss with you!

Code

If you want to see the full code of the example, you can find it here on Github.

References

This article was inspired by a conference by Félix-Antoine Bourbonnais and Ian Létourneau at Agile Tour de Québec 2019. Watch the full presentation on Youtube.

Join the conversation

or to participate.