Components Genesis

Components Genesis

Are you not happy with the way your React code looks? This article offers a deep dive into the Top-Down Code Design approach, which can help you write code that is reliable, easy to work with, and professionally looking. Examples and tips included. Let's dive in, shall we?

Problem First, Right?

So, let's say we are writing a new React component: some use-states and use-effects here and there, some JSX layout, and maybe styling. Then more use cases come, and so we add more states, effects, more JSX lines, event handlers, and, oh snap, here we go again - it's too big and messy. Something like this AI-generated snippet, although it could be much bigger:

Is that always wrong? It depends on how often such a component would be changed. In files like this, searching for things, modifying them, and making sure nothing breaks can be challenging, while proper code structuring and separation can save many hours and reduce work stress. Such a picture is acceptable for a periphery, stale feature, written once and forgotten. However, for core features, for things you actively change and develop, bloated files are sources of trouble.

So we can try to split it into smaller components, but there's never enough time; some intuitive separation we quickly come up with doesn't feel perfect, but it's kind of working, and the pull request gets accepted. We keep adding new code around day by day, and we see that we didn't solve the problem, the size and complexity keep increasing. At some point, eventually, we greet the newcomer with an unspoken vibe of "Well, it is what it is". Impostor syndrome creeps in. Shall we refactor properly? How? Can we make it better? Hypothetically yes, but changing is always much harder than just writing. Also, we have deadlines, we have to add new features, we don't have enough time to figure things out. And so, our recently written code has all chances to become a "legacy" way too early.

Most likely, you've experienced some version of this story yourself. Don't feel too bad, my friends, and not just because "we've all been there". I believe React in itself is the reason (what a twist, huh?).

React and "Procedural Thinking"

A hot take: React is not designed to push you toward writing good code. Yes, it's a library, not a framework. Yes, it is unopinionated™, so it's kind of "not a bug but a feature". But seriously, in its raw form, it is rather good for delivering a good user experience quickly with a relatively small amount of unstructured code. Because of that, we intuitively code in procedures. What is a procedural code? According to Wikipedia:

Procedures simply contain a series of computational steps to be carried out.

So, just a listing of all the instructions. C and Pascal are procedural languages; if you are familiar with that lore, you might find some resemblance between C/Pascal programs and generic React code. React component with hooks and event handlers is basically a procedure unless you choose to do something clever about it.

There are many ways in which Procedural Programming is a weak concept for satisfying the needs of modern businesses, but in the scope of our topic here: it is not a good way to write scaleable or reusable code. So if I were to leave you with one new thought from here, it would be this: as a Frontend developer who works with React, your goal is to escape the cage of thinking in procedures.

Our sad story from the beginning never happens if we aim to write React components not as procedures in the first place.

"I Am Known by Many Names"

The more advanced technique I want to share with you is technically nothing new. Some parts of it hide behind the sophisticated nickname "Coding Against the Interface", which originates from Object Oriented Design lands; it is also known under the name "Wishful Programming", and Steve McConnel, in his book "Code Complete" calls it the "Top Down Design Approach". In this article, I'll refer to it as "Top-Down Code Design".

Most developers I worked with usually write their code step by step, change by change, followed by the app launch to check if it still works correctly. While this is a completely legit way to code, I recommend learning how to do things differently.

"Top-Down Code Design" in a Nutshell

Instead of implementing the code that works, start with code structure first, but use your imagination - imagine someone already implemented all the parts you need for your feature, and your task is to integrate these parts. Use components that don't exist yet, functions that are not yet implemented, and custom hooks you just made up. Avoid writing primitive expressions. Refine the design until it looks like it's going to work. Use the same principles on lower levels if applicable. Only after that, start implementing.

And so we call it "Top-Down Design" because we first design the code "from the top", and only then we move to "lower levels" to implement it.

Let's see an example. We will "build" a tiny email client, shall we?

Example

Let's say we are working on something that looks like this:

We render a list of emails with a button to delete selected emails. There also would be a loader and an error screen.

So for starters, I imagine I already have a hook that can load emails, useEmails(). Also, I imagine ready-to-use components to show in corner cases (loading, error, and empty states). And, of course, I need to show the data - here comes a component called <EmailsTable/>.

Please note, all the examples in this article have to be treated only as pseudocode illustrating the process of thinking.

export const Emails = () => {
  const { emails, status } = useEmails()

  if (status.isLoading) return <Loader />

  if (status.error) return <EmailsError error={status.error} />

  if (isEmpty(emails)) return <EmptyEmailsScreen />

  return (
    <EmailsTable>
      {emails.map(email => (
        <EmailsTable.Row email={email} />
      ))}
    </EmailsTable>
  )
}

So here, I only think about declaring functions, hooks, and components to delegate the job to. I don't think about how exactly I will implement them.

Notice that I also don't check for empty emails with if (emails.length === 0), as this is an inline primitive expression; instead, I imagine I already have a method isEmpty() for that.

Now, let's manage the status of selected emails. Typically, we'd do something like this:

export const Emails = () => {
  // ...
  const { selected, setSelected } = useState({})
  // ...
  const toggleEmailSelected = (id) => {
    setSelected({
      ...selected,
      [id]: !selected[id]
    })
  }

  return (
    <EmailsTable>
      {emails.map(email => (
        <EmailsTable.Row 
          // ...
          isSelected={selected[email.id]}
          onToggleSelected={toggleEmailSelected}
        />
      ))}
    </EmailsTable>
  )
}

But that would be "procedural thinking". Instead, let's delegate it to a dedicated imaginary hook:

export const Emails = () => {
  // ...
  const { selected, toggleSelected } = useSelectedItems()
  // ...
  return (
    <EmailsTable>
      {emails.map(email => (
        <EmailsTable.Row 
          // ...
          isSelected={selected[email.id]}
          onToggleSelected={toggleSelected}
        />
      ))}
    </EmailsTable>
  )
}

Now, let's design the deletion of selected items:

export const Emails = () => {
  const { emails, status, deleteEmails } = useEmails()
  const { selected, toggleSelected } = useSelectedItems()
  // ...
  function deleteSelection() {
    deleteEmails(selected)
  }

  return (
    <>
      <button onClick={deleteSelection}>Delete</button>
      <EmailsTable>
        {emails.map(email => (...
        ))}
      </EmailsTable>
    </>
  )
}

Here again, I imagine that my hypothetical hook useEmails() already has deleteEmails() method. And I assume it would be convenient for me as a user of that code to pass data from useSelectedItems() into the deleteEmails() function call as params.

Let's dive into useEmails(), procedurally we could do something like this:

export const useEmails = () => {
  const [emails, setEmails] = useState([]);
  const [status, setStatus] = useState({ state: "idle" });

  useEffect(() => {
    const fetchEmails = async () => {
      setStatus({ ...status, state: "loading" });
      try {
        const response = await fetch("/emails");
        setEmails(await response.json());
        setStatus({ ...status, state: "done" });
      } catch (e) {
        setStatus({ ...status, state: "fail", error: e.message });
      }
    }

    fetchEmails()
  }, [api, setEmails, setStatus]);

  return {
    emails,
    status,
  };
};

But using a Top-Down approach, I won't be doing that; instead, I'm going to delegate a request status change to non-existent-yet useStatus(), and my API interactions will be performed by a useEmailsApi() hook "from the future":

export const useEmails = () => {
  const api = useEmailsApi();
  const [emails, setEmails] = useState([]);
  const { status, setStatus } = useStatus();

  useEffect(() => {
    const fetchEmails = async () => {  
      setStatus("loading");
      try {
        setEmails(await api.fetchEmails());
        setStatus("done");
      } catch (e) {
        setStatus({ error: e.message });
      }

    fetchEmails()
  }, [api, setEmails, setStatus]);

  return {
    emails,
    status,
  };
};

What's left here is to implement deleteEmails(). Here I foresee my useEmailsApi() will have a method remove(ids). I also assume that within deleteEmails(), it would be convenient to split emails into two groups - ones to delete and ones to keep - so I imagine I already have a partition(list, condition) method, which would return two arrays - with items fulfilling the condition and items that don't:

export const useEmails = () => {
  const api = useEmailsApi();
  const [emails, setEmails] = useState([]);
  const { status, setStatus } = useStatus();

  useEffect(() => {...
  }, [api, setEmails, setStatus]);

  async function deleteEmails(ids = {}) {
    setStatus("loading");

    const [emailsToDelete, emailsToKeep] = partition(
      emails,
      ({ id }) => !!ids[id]
    );
    setEmails(emailsToKeep);

    try {
      const ids = emailsToDelete.map(({ id }) => id);
      await api.remove(ids);
      setStatus("done");
    } catch (e) {
      setStatus({ error: e.message });
    }
  }

  return {
    emails,
    status,
    deleteEmails,
  };
};

So again, at this stage, I don't write the code to select the emails to delete, and I don't care how exactly the deletion will happen.

After this, I'd do the same with the rest of the code. What should be the next step?

Don't Rush to Implement

I highly recommend traversing the tree of our components, hooks, and functions in a "Breadth First" order. In our current example, I would not proceed with implementing useEmailsApi(). Instead, I will return, for example, to <EmailsTable />. The thing is, some ideas I've sketched might be wrong. By postponing the implementation, we can identify code design mistakes earlier and avoid wasting time coding something that doesn't make sense conceptually. Sometimes we might find that we need to rework everything. But hey, technically, we didn't do anything yet, so it's not painful at all.

Obviously, our ability to identify code design mistakes depends on knowledge and experience. But the main things to remember:

  • The data flow should be clear, easy to follow.

  • There should be no conflicting sources of truth.

  • Data should be available where you need it.

  • Code shouldn't look "out of place".

Also, it's important to check for potential future use cases. Of course, you can't predict everything, so don't stress out much about it. But not doing that would be a mistake. For instance, in my design above, I already see that there is no good way to support selecting multiple emails while holding "Shift". Also, if I add more functionality, my useEmails() hook will become bloated.

Pro tip: show your design to someone. Explain what are you doing and why, ask for opinions. Don't hide drafts from the others - insights from other people can play a crucial role in identifying problems early.

Mastering the Craft

Once you get used to this approach, you will have numerous gains: your code most likely will be SOLID-ish out of the box, readability will improve, and testability will become incredibly pleasing. Eventually, with more experience and more reusable things introduced, the whole coding experience might even start to feel like assembling LEGO.

However, switching to such a way of working is not easy. It requires a mental shift and practice. As I mentioned, most developers I worked with prefer going small steps and launching the code every now and then to check if it still works. Also, there is a popular concern: "But how do you debug? Creating a lot of code at once, implementing it weirdly, probably everything will fall apart when launched?" Here is the coolest part about debugging in this approach. You stop doing it (eventually).

True story - after I gained confidence in this approach, I stopped launching my application before finishing everything till the end. Like, at all, for days. Of course, I still get an exception here and there while testing, but after a few fixes (usually missed null-checks caught by DevTools), everything works. It is possible, I promise.

It is hard to give a one-fits-all solution to mastering this way of working. But here are some bits of advice.

Structure Works If Functions Work

If you've done everything right, visually inspecting the code structure is usually enough to understand if it will work. From the examples above: if you call fetchEmails(), it will fetch emails, and if there are no emails, the list will not be rendered. There usually should be no doubts about it; unless it's something complicated, it will work as long as components and functions you declared will work. So the task is to make sure they will, here's how:

  • Always learn the tech you use, always read the docs - never use anything without a decent understanding of what it can do and how it works. Not sure what fetch() returns? Not sure how useEffect() works? Read the docs, don't guess.

  • Test in sandboxes if in doubt - it can be a blank test page in your project or some online playground. Testing components in isolation is way cheaper and faster than assembling things to see if they work.

  • Write tests to be completely sure - this is the best for functions and hooks, as tests are cheap to write and easy to run. And you probably will need to write them anyway at some point, right?

Remember: by defining “building blocks” with Top-Down Approach, you get clear requirements for them; focus on fulfilling these requirements during implementation, and everything will fit into place.

Name Things Precisely

To be able to verify the code structure visually, the name of your components and especially of your functions have to represent everything they are doing precisely.

For example, if you fetch and validate something from the API, but your function is called fetchSomething(), you are hiding the important part of what the function is doing. fetchAndValidateSomething() would be a better name. Too long name? Split into two functions with shorter names.

Also, avoid abstract names. In the example below, we have a handler for the button click with three different actions underneath:

export const SomeComponent = () => {
  function onButtonClick() {
    // ...close the modal
    // ...format items
    // ...send request
  }

  return (
    <>
      <button onClick={onButtonClick}>OK</button>
      { /* ... */ }
    </>
  )
}

Notice that such a handler would actually be a procedure, but it is a completely legit use case for it. What is still missing is a precise name. In our case, a better name would be:

export const SomeComponent = () => {
  function proceedWithItemsSubmission() {}

  return (
    <>
      <button onClick={proceedWithItemsSubmission}>OK</button>
      { /* ... */ }
    </>
  )
}

Some Integration Code Might Be Needed

A good practice is to declare the functions' interfaces in a way convenient for the functions themselves. Say, for our example above, we want to write deleteEmails() method differently; instead of optimistically updating the list, we send the request to remove the items, and then we re-fetch the data:

async function deleteEmails(ids = {}) {
  setStatus("loading");
  try {
    await api.remove(
      getSelectedIdsList(ids) // {a:true,b:true,c:false} -> [a,b]
    );     
    setEmails(await api.fetchEmails());
    setStatus("done");
  } catch (e) {
    setStatus({ error: e.message });
  }
}

If we were to design this method in isolation, we'd not have many reasons for accepting ids as map. So why should we now?

  async function deleteEmails(ids = []) {
    setStatus("loading");
    try {
      await api.remove(ids);
      setEmails(await api.fetchEmails());
      setStatus("done");
    } catch (e) {
      setStatus({ error: e.message });
    }
  }

Therefore, the conversion to the array should happen somewhere in between, and it would be completely fine to implement it inside the main component:

function deleteSelection() {
  const ids = getSelectedIdsList(ids) // {a:true,b:true,c:false} -> [a,b]
  deleteEmails(ids)
}

This can be considered an "integration code", and you shouldn't be scared of writing such.

Beware of Spaghetti

The so-called "Spaghetti Code" as a type of code smell is a separate topic that would deserve an article on its own (and many are already written; look it up!). In Top-Down Code Design, you usually eliminate most spaghetti-ness out of the box, but you may fall into the trap of long function call chains.

While writing code top-down, it's very easy to get carried away and start creating "sub-levels", which can complicate navigating the code. In our Emails example, I could do something like this:

export const useEmails = () => {
  const { emails, reloadEmails, status: fetchStatus } = useFetchEmails();
  const { deleteEmails, status: deleteStatus } = useDeleteEmails(reloadEmails);

  return {
    emails,
    fetchStatus,
    deleteEmails,
    deleteStatus
  };
};

While there might be some benefits in it, in this case, it would only create a redundant layer between a component using it and the actual hooks. If we'd really believe we need to separate our code like this, most likely, we won't need useEmails(), and we could just use those two hooks in the main component.

Tap on Your Colleague's Shoulder

As I already mentioned, you shouldn't be afraid to review your code design with teammates. They may spot mistakes, point out use cases that are not covered, remind you about existing functions you could reuse, etc.

Don't forget, however, that some people might be confused with such an approach. So if that might be the case, it may be worth sharing this article with them first.

Commit Every Step

Technically this is always a good thing to do. I would also recommend reading the article "Commit Message Driven Development".

Within Top-Down Code Design, having a clean commit history with meaningful messages is not the primary concern. What is important is to break the process into steps, allowing you to quickly double-check the diff to see if you aren't missing anything, roll back quickly, etc.

Summary

To avoid creating React code that looks like a legacy from the start, you can use the Top-Down Code Design approach: start with writing code using functions, components, and other primitives that are not written yet, polish this structure, and only then implement it. Once mastered, this approach helps to write more reliable, readable, and sustainable code.

Like and Subscribe

I hope the Top-Down Code Design approach will help you level up your coding game and make your life easier. Try it, and share your impressions in the comments!

I would also recommend reading "Code Complete" by Steve McConnell, where I saw this idea for the first time many years ago. It's not the easiest read, and it is completely unadapted for modern frontend development, but many parts of it are pure gold.

And, if you want to know more about writing more sustainable code, I'd also recommend warming up some milk, taking some cookies, and reading my previous article, The Idea Behind SOLID...

Also, follow me on Hashnode, and feel free to connect on socials: