DEV Community

William R. J. Ribeiro
William R. J. Ribeiro

Posted on

UI library agnostic?

One of the most desirable qualities of any codebase is low coupling since this enables easy and orderly ways to change it. Easiness of change is what enables product teams to add or remove features at a fast pace which in turn makes the product more agile.

The user interface is one of the parts with the most frequent changes therefore, its code must be as easy to change as possible. I have worked with a handful of UI libraries and noticed that usually the front-end code is very coupled to whatever lib being used.

What if you could have the front-end code so decoupled that changing the UI library would not be a total rewrite?

Imagine that one day your customers are completely fed-up that your product is super slow and that the culprit is the front-end which is completely bloated. Your team decides to do a total rewrite with a different UI library that is focused on being lightweight. It would be a big win if any working code could be salvaged and reused.

I decided to do a little experiment to try answering my question. My goal was to create a front-end only Todo App with some constraints:

  • Minimal and simple: no need to add many features. The goal is to show how decoupled the core can be from the UI library.
  • The core functionalities must be implemented with vanilla JavaScript and have no external dependencies.
  • The core TodoApp must be exactly the same regardless of UI library.
  • Implement the UI with vanilla JavaScript.
  • Implement the UI with React.
  • Implement the UI with Svelte.
  • Use Tailwind CSS for the styles.

As you can see from the TodoApp tests, it’s pretty basic:

  • The todo item data is: { id: number, title: string, done: boolean }.
  • Add, remove, and edit todo items by id.
  • Get all items.
  • Filter the todo items by done or not done.
  • Throws errors when:
    • creating an item without a title.
    • deleting an item that is not done.
    • trying to perform an operation in an inexistent item.

The UI is also simple:

  • A required text input field for typing the todo.
  • A submit button to add the item. Enabled only when the input has some value. This prevents errors from trying to add an item without a title.
  • A radio group with 3 options for selecting the active filter: all, active (not done), or done.
  • A list for showing the items based on the selected filter.
  • Each item displays its title. If the item is done, add a line through it and add a delete button next to it. This prevents errors from trying to delete items that are not done.
  • Each item has a checkbox for marking it done or not.
  • A footer with the name of the UI library being used.

Unfortunately, I didn’t have the will to write tests for any of the UI implementations. Ideally, I would like to have just one implementation of automated tests and it would work independently of UI library as well. Maybe in another blog post I can explore this.

I decided to start with the vanilla JS implementation. An important question arose early on code: How’s going to be the dependency between the UI and the app code?

Designing a plug for the UI

The first approach was pretty straight forward: the UI imports the app. When the UI is initialized, it creates an instance of the TodoApp and can easily call all its functions and access all the data.

// 1st approach of vanilla JS UI
import TodoApp from "./app";

export default function VanillaUI() {
  const app = new TodoApp();
  // ...
  return {
    init: () => {/* ... */}
  };
}

This approach had a few bad code smells:

  1. The app “lives” inside the UI completely encapsulated which is a super high coupling.
  2. Hard to test since it’s not possible to mock any data or functionality in an elegant way.
  3. Any API changes in the TodoApp breaks the UI.

In the second approach, I used dependency injection: instead of the UI importing the TodoApp, an instance is given when the UI is initialized. This solved the first two issues from the first approach. Some code is now necessary to integrate both: it initializes the app and the UI and passes the reference of the former to the latter.

// 2nd approach for implementing VanillaUI.
export default function VanillaUI() {
  let app;
  // ...
  return {
    init: (todoApp) => {
      app = todoApp;
      // ...
    }
  };
}
// index.js - Integrates TodoApp and UI.
// Integration code
import TodoApp from "./app";
import VanillaUI from "./vanilla.ui.2";

const app = new TodoApp();

VanillaUI().init(app);

In the third approach, to solve the last remaining code smell, I used inversion of control: the UI provides an interface for the functionalities it depends on to operate. Since the UI code depends on something it controls, it’s completely safe from any external changes.

If you’re into types, here’s how the overall idea would look like in TypeScript:

UI interface in TypeScript
interface TodoItem {
    id: number;
    title: string;
    done: boolean;
}

interface UIDependencies {
    getAll: () => Promise<TodoItem[]>;
    getDone: () => Promise<TodoItem[]>;
    getNotDone: () => Promise<TodoItem[]>;
    onAddItem: (item: TodoItem) => Promise<number>;
    onTodoChange: (item: TodoItem) => Promise<number>;
    onDeleteItem: (todoId: number) => Promise<number>;
}

function VanillaUI(adapter: UIDependencies) {/* ... */}

As a good measure, the UI dependencies is asynchronous. The UI updates/re-renders only when the TodoApp is done with its work and resolves the promise.

The integration code has a bit more work to do now: it must implement the UIDependencies interface and call the right TodoApp functions when needed.

// 3rd approach of vanilla JS UI
export default function VanillaUI(uiDeps) {
  // ...
  return {
    init: () => {/* ... */}
  };
}

// Integration code
import TodoApp from "./app";
import VanillaUI from "./vanilla.ui.3";

const app = new TodoApp();

const uiDeps = {
  getAll: async () => app.todos(),
  getDone: async () => app.filters.done(),
  getNotDone: async () => app.filters.notdone(),
  onAddItem: async item => app.add(item),
  onTodoChange: async ({ id, done }) => {
    app.edit(id, { done });
    return app.todos().find(todo => id === todo.id);
  },
  onDeleteItem: async id => {
    app.delete(id);
    return id;
  }
};

VanillaUI(uiDeps).init();

Plugging in different UIs

Once I was happy enough with the results of the Vanilla JS implementation, I started with the React implementation. I followed the 3rd approach of the Vanilla JS implementation as the basis.

The React implementation is straight forward even though it’s a bit verbose. I even tried a “god component” with multiple useState(). It reduced the amount of code by a good amount but it’s still unnecessarily hard to read. I guess React is just too verbose in nature. 🤷‍♂️

// ... code redacted for brevity ...
export default function ReactUI({
  uiDeps
}) {
  const [{ todos, inputValue, activeFilter }, dispatch] = useReducer(
    reducer,
    initialState
  );

  const fetchTodos = async filter => {
    let getTodos = getAll;
    if (filter === Filters.ACTIVE) {
      getTodos = getNotDone;
    } else if (filter === Filters.DONE) {
      getTodos = getDone;
    }

    const todos = await getTodos();
    dispatch({ type: SET_TODOS, todos });
  };

  useEffect(() => {
    fetchTodos(activeFilter);
  }, [activeFilter]);

  const handleSubmit = event => {
    event.preventDefault();

    onAddItem({ title: inputValue }).then(() => {
      fetchTodos(activeFilter);
    });

    dispatch(clearInput);
  };
}

// Integration code
import React from "react";
import ReactDOM from "react-dom";
import TodoApp from "./app";
import ReactUI from "./react.ui";

const app = new TodoApp();

const uiDeps = {
  // Identical to vanilla JS ...
};

ReactDOM.render(
  <React.StrictMode>
    <ReactUI uiDeps={uiDeps} />
  </React.StrictMode>,
  document.getElementById("root")
);

The Svelte implementation was a breeze of fresh air! It was by far the easiest UI to implement and I have zero experience with it. I ended up doing a “god component” but it was not intentional. I really just don’t know yet how to create internal private components in Svelte. ????

// Svelte UI excerpt
<script>
  export let uiDeps = null;

  let inputValue = "";
  let selectedFilter = "all";
  let todos = [];

  function onFilterClick(filter) {
    selectedFilter = filter;
    fetchTodos();
  }

  async function fetchTodos() {
    let getTodos = uiDeps.getAll;
    if (selectedFilter === "notdone") {
      getTodos = uiDeps.getNotDone;
    } else if (selectedFilter === "done") {
      getTodos = uiDeps.getDone;
    }

    todos = await getTodos();
  }
  // code redacted for brevity ...
  fetchTodos();
</script>
// Integration code
import SvelteUI from "./svelte.ui.svelte";
import TodoApp from "./app";

const app = new TodoApp();

const uiDeps = {
  // identical to vanilla JS ...
};

// TODO add <html> and <body> Tailwind classes
const ui = new SvelteUI({
  target: document.body,
  props: { uiDeps }
});

export default ui;

Conclusion

That was a really fun experiment! It’s definitely possible to make your front-end code agnostic of the UI library being used. The integration between the two becomes very explicit and can be more or less decoupled.

Now, is it practical? Is it worth it? To be honest, only real-life experience will tell. The UI code in all three libraries was much bigger than the core application. Maybe we could have put some more logic in the core app that would simplify the UI but I don’t think it would make a big difference.

The deal-breaker lies in the UI code that can become more complex, even awkward since part of the state – the business part – is managed externally. The Return on Investment of this approach would only come later when the UI library indeed has to change.

That’s it! Let me know if the code could be improved especially in Svelte that I’m a total noob.

Cheers!

Top comments (0)