Valstar.dev

Workshop - Midjourney

Leveraging Your Tools

14 min read


To some perfection is achieved when there is nothing more to add. To other’s perfection is achieved when there is nothing left to take away. - Antoine de Saint Exupéry

Modern Tooling

Modern tools you build your applications with are the results of lessons learned (or unlearned) over decades and are nearly universally designed to reduce the amount of work the designers of that tool have to do.

Often I see this tooling being used either for a single feature they provide or as a kludge to solve a problem problem the developer didn’t want or know how to solve. There is often discussions on various tools about using them “the wrong way” or “not as intended”, I think the problem is that you’re just not taking full advantage of the tool(s) you’re using.

Your Tools

Now you might say you don’t use any tools, you like to write everything from scratch. But if you’re building a website you’re probably using Javascript, CSS, HTML, and if it’s heavily interactive you’re probably using a framework like React, Vue, or Svelte. All of these have tons of features that you can leverage even if the library is designed to be small.

Lets take React for example, there are over 15 built in hooks but in most code bases I only see useState and useEffect being used. When was the last time you used useId to link a label to a form element?

Leveraging Those Tools

For a more complex example, I’d like to talk about Apollo Client. Often when I see this being used, it’s used for the most basic feature it offers… Queries and Mutations. But Apollo adds in a ton of additional functionality that can be used to make your life easier, your code cleaner and reduce the amount of server calls you make.

Lets take the following example where we have a simple list. This is a common pattern I see people using in their apps to have a list of items that you can add or remove to.

For all examples we’re using hte following queries:

const GET_BOOKS = gql`
  query GetBooks {
    books {
      id
      title
      deleted
    }
  }
`;

const ADD_BOOK = gql`
  mutation AddBook($title: String!) {
    bookAdd(title: $title) {
      id
      title
      deleted
    }
  }
`;

const ADD_DEL = gql`
  mutation RemoveBook($id: ID!) {
    bookRemove(id: $id) {
      id
      title
      deleted
    }
  }
`;

The Common Pattern

There are 2 common patterns I see, I’ll call them the “Leveraging the Server” and the “Everything in State” patterns.

Leveraging the Server

This is the most common pattern I see, which is just calling a refetch every time a change is made and falling back to the server to handle your applications state.

function ServerIt() {
  const [title, setTitle] = useState("");

  const { data, error, refetch } = useQuery(GET_BOOKS);
  const [addBook] = useMutation(ADD_BOOK);
  const [deleteBook] = useMutation(ADD_DEL);

  const deleteClick = (e: React.MouseEvent, id: string) => {
    e.preventDefault();
    deleteBook({
      variables: { id },
      onCompleted: () => {
        refetch();
      },
      onError: (err) => {
        console.log(err);
      },
    });
  };

  const addClick = (e: React.MouseEvent) => {
    e.preventDefault();
    addBook({
      variables: { title },
      onCompleted: () => {
        refetch();
      },
      onError: (err) => {
        console.log(err);
      },
    });
    setTitle("");
  };

  if (error) {
    console.log(error);
    return <>Error!</>;
  }

  return (
    <div>
      <ul>
        {data?.books.map((book: { title: string; id: string }) => (
          <li key={book.id}>
            {book.title}
            <button onClick={(e) => deleteClick(e, book.id)}>Delete</button>
          </li>
        ))}
      </ul>
      <form>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
        <button onClick={addClick}>Add</button>
      </form>
    </div>
  );
}

Everything in State

The next most common pattern I see is that people leverage react state to handle everything.

function Stateful() {
  const [title, setTitle] = useState("");
  const [books, setBooks] = useState<
    Array<{ title: string; id: string; deleted: boolean }>
  >([]);

  const { error } = useQuery(GET_BOOKS, {
    onCompleted: (data) => {
      if (data?.books) {
        setBooks(data.books);
      }
    },
  });
  const [addBook] = useMutation(ADD_BOOK);
  const [deleteBook] = useMutation(ADD_DEL);

  const deleteClick = (e: React.MouseEvent, id: string) => {
    e.preventDefault();
    deleteBook({
      variables: { id },
      onCompleted: (data) => {
        if (data?.bookRemove) {
          setBooks(books.filter((b) => b.id !== id));
        }
      },
      onError: (err) => {
        console.log(err);
      },
    });
  };

  const addClick = (e: React.MouseEvent) => {
    e.preventDefault();
    addBook({
      variables: { title },
      onCompleted: (data) => {
        if (data?.bookAdd) {
          setBooks([...books, data.bookAdd]);
        }
      },
      onError: (err) => {
        console.log(err);
      },
    });
    setTitle("");
  };

  if (error) {
    console.log(error);
    return <>Error!</>;
  }

  return (
    <div>
      <ul>
        {books.map((book) => (
          <li key={book.id}>
            {book.title}
            <button onClick={(e) => deleteClick(e, book.id)}>Delete</button>
          </li>
        ))}
      </ul>
      <form>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
        <button onClick={addClick}>Add</button>
      </form>
    </div>
  );
}

The Problems

Both of these patterns have some issues, but I will note that the refetch option has the least issues.

For the stateful version, you will find that if you have multiple components that need to access the same data you will need to either pass the data down or use a global state management tool like Redux or Recoil. This will further complicate your application then it needs to be.

For the server version, you will find that you are making a lot of calls to the server. This adds additional un needed overhead to your application and can also cause some level of rendering issues in react if you’re not careful.

Using The Tool

In my example below I’m taking advantage of the Apollo client, with it’s biggest feature: the cache. This cache acts like a single source of truth for your application and allows for some amazing automatic features.

I have used 2 of those features in this example. The first is the automatic cache update based on the ID of the object. When we delete a book, we return that same book in our response from the server and Apollo will automatically set the deleted flag to the changed value. This can also be used when updating an object’s values.

The 2nd technique I used is the cache.modify function. This allows you to modify the cache directly. In this example I’m adding the new book to the cache directly. This allows us to avoid a server to refresh the list of books similar to the everything in state example, but without the need to manage the cache yourself. And the added benefit that if you have multiple components that need to access the same data, they will all be updated automatically.

function addNoteToCache(cache: any, { data }: { data?: any }) {
  if (data) {
    cache.modify({
      fields: {
        books(existingBooks = []) {
          const newBookRef = cache.writeFragment({
            data: data.bookAdd,
            fragment: gql`
              fragment NewBook on Book {
                id
                title
                deleted
              }
            `,
          });
          return [...existingBooks, newBookRef];
        },
      },
    });
  }
}

function Leveraged() {
  const [title, setTitle] = useState("");

  const { data, error } = useQuery(GET_BOOKS);
  const [addBook] = useMutation(ADD_BOOK);
  const [deleteBook] = useMutation(ADD_DEL);

  const deleteClick = (e: React.MouseEvent, id: string) => {
    e.preventDefault();
    deleteBook({
      variables: { id },
      onError: (err) => {
        console.log(err);
      },
    });
  };

  const addClick = (e: React.MouseEvent) => {
    e.preventDefault();
    addBook({
      variables: { title },
      update: addNoteToCache,
      onError: (err) => {
        console.log(err);
      },
    });
    setTitle("");
  };

  if (error) {
    console.log(error);
    return <>Error!</>;
  }

  return (
    <div>
      <ul>
        {data?.books
          .filter((f) => !f.deleted) // Filter out deleted books
          .map((book: { title: string; id: string }) => (
            <li key={book.id}>
              {book.title}
              <button onClick={(e) => deleteClick(e, book.id)}>Delete</button>
            </li>
          ))}
      </ul>
      <form>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
        <button onClick={addClick}>Add</button>
      </form>
    </div>
  );
}

Although this solution is more lines of code, it can be more easily broken up into custom hooks for the mutations and should, once built not need to be touched even if other portions of your applications change the data this component references.

If you’re going for quick and dirty the refetch option works, but will cause a lot more “loading” bars to appear in your application. But if you’re going for a more robust solution that will scale with your application, leveraging the cache is the way to go.

A quick comparison for the examples above with a simple example of loading the component adding 1 book to the list, then removing it: (not counting any renders for the form input)

Leveraging the ServerEverything in StateLeveraging the Cache
Renders877
Server Calls533

Counter Example

Using the tool as intended, or fully utilizing it can also have it’s downsides. A common example of this is with ORMs; ORMs are great for simple fetch and updates but when you start to get into more complex queries they start to show their limitations.

For instance Prisma famously doesn’t support joins, and instead makes multiple calls to the database to get the data you need. This can cause performance issues. In this instance you probably want to just write the SQL yourself (still using the ORM to handle the connection) and not use the built in query builder(s).

Final Thoughts

Overall the speed and ease of development is my goal when developing applications. I want to be able to build something quickly and easily. Sometimes that means sacrificing some performance (when it can be justified). And doing that if often much easier if I take the time to learn all the features my tools offer (especially when a new version comes out).