How (Not) To Fail a React Technical Screen

I've spent the past few weeks conducting technical screens for prospective React developers. Without giving too much away (or getting caught up in the specifics of the task), I give candidates an empty Create React App project and two REST API endpoints. I ask them to retrieve a list of data from the REST API endpoints and to display this list in the browser.

And... that's it. There's nothing tricky here, and you definitely don't have to write clever code. I deliberately designed my task to be easy for someone who's experienced with React and do-able for more junior candidates; we're hiring junior and mid-level engineers, so we needed a way to suss out someone's level.

Conducting this screen has been an eye-opening experience. I learned a lot about how people are using React in practice, for better or for worse. Mostly for worse. We still had about a 50% reject rate even though I (thought I) went out of my way to design an easy screen.

Here's the advice I'd give to the 50% of candidates who didn't do so well.

1. Work outside in

Working outside in means starting with the visible parts of your UI -- the markup and styles -- before you address your application's state management and business logic. When you're working with React, it's very difficult to make good decisions about state management unless you've already made decisions about the UI you're going to render.

A web UI is, at its core, a tree of HTML elements. In traditional web apps, application state was represented in this HTML tree directly. The most extreme example of this is a server-rendered application: whenever the application state changes, the client must request an entirely new HTML document from the server. Each change in state requires the browser to re-render the entire document.

A less extreme example would be something like a typical jQuery plugin. For example, Bootstrap's jQuery codebase stuffs application state into HTML data attributes and CSS classes, and might also directly inject or remove HTML elements entirely. Each change in state requires the browser to re-render some (potentially large) subtree of the HTML document.

In React applications, your state lives at the component level. A transition from one state to another causes its component to re-render. State can only be passed in one direction -- from parent to child -- and it must be passed explicitly. These constraints allow React to efficiently determine what minimal subtree of components need to re-render in response to a change in state.

Rendering is expensive. We want to minimize the amount of rendering work we need to do.

For the best possible React performance, we need to be thoughtful about where state is located in the component hierarchy. Ideally, we'll organize our state such that changes in state trigger the smallest possible re-render. And this means that we need to think about the HTML we're rendering up front.

For example, suppose we want to render a table that shows books alongside their average review scores. We get a list of books from a /books endpoint via. a useBooks() hook. Where in our React component hierarchy should we make this network request and manage its state?

Working outside in, we decide that our markup will look something like this:

<table>
    <thead>
        <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Genre</th>
            <th>Average Score</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>For Whom the Bell Tolls</td>
            <td>Ernest Hemingway</td>
            <td>Literature</td>
            <td>3.97</td>
        </tr>  
        <!-- more rows... ->  
    </tbody>
</table>

Well, it will look that way once we've loaded the list of books. While we're waiting for the list of books to load, it might look more like this:

<table>
    <thead>
        <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Genre</th>
            <th>Average Score</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td colspan="4">Loading...</td>
        </tr>    
    </tbody>
</table>

And if the list of books fails to load, it will probably look like this:

<table>
    <thead>
        <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Genre</th>
            <th>Average Score</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td colspan="4">Something went wrong</td>
        </tr>    
    </tbody>
</table>

Finally, we might end up with an empty list of books. This could happen if the user has logged in for the first time and hasn't set up any data yet. It could also happen if we have object-level permissions: a user might be allowed to access the /books endpoint without having permission to view any of the objects it returns. This edge case is also pretty straightforward to handle:

<table>
    <thead>
        <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Genre</th>
            <th>Average Score</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td colspan="4">No books to display</td>
        </tr>    
    </tbody>
</table>

Notice how for each of these possible 4 states of our books table, the only markup that changes is inside <tbody>. This implies that if we want to minimize unnecessary re-rendering, we should scope our request-related state to a subcomponent responsible for rendering just the table body.

Our component hierarchy might look like this:

const BookTableBody = () => {
    const { isLoading, errorMsg, books } = useBooks();

    if (errorMsg) {
        return <tr><td colspan="4">{errorMsg}</td></tr>
    }
    
    if (isLoading) {
        return <tr><td colspan="4">Loading...</td></tr>
    }
    
    if (!books.length) {
        return <tr><td colspan="4">No books to display</td></tr>
    }
    
    return (
        <>
            {books.map(book => (
                <tr key={book.id}>
                    <td>{book.title}</td>
                    <td>{book.author}</td>
                    <td>{book.genre}</td>
                    <td>{book.avgRating}</td>
                </tr>
            ))}
        </>
    )   
};

const BookTable = () => (
    <table>
        <thead>
            <tr>
                <th>Title</th>
                <th>Author</th>
                <th>Genre</th>
                <th>Average Score</th>
            </tr>
        </thead>
        <tbody>
            <BookTableBody />    
        </tbody>
    </table>
)

Now, whenever our request for books transitions from one state to the next (e.g. loading -> has data), only the BookTableBody will re-render. We aren't unnecessarily re-rendering markup that doesn't need to change, like the table headers.

2. Manage the complete lifecycle of a request

In the example above, our simple request for a list of data resulted in four distinct states our application could be in at any given moment:

  1. Loading: A request is in-flight
  2. Error: Our request either returned with a server error or failed entirely (e.g. if the user lost their internet connection)
  3. Empty: Our request returned successfully, but the list of data it got back was empty
  4. Success: We successfully retrieved a list of data

In your local development environment, you may only ever encounter the fourth state. Data loads almost instantaneously since requests over localhost have virtually no latency. You rarely encounter errors and if you do, you treat it as a bug in the server-side codebase and handle it there.

If you never stray outside your local development environment, you might end up writing hooks that look like this:

const useFoo = () => {
    const [foo, setFoo] = useState(null);
    
    useEffect(() => {
        fetch('http://example.com/api/foo')
            .then((res) => {
                return res.json();
            })
            .then((json) => {
                setFoo(json);
            })
    }, []);
    
    return { foo };
}

Your local dev environment is highly unrealistic. In reality, network requests can take several seconds to return depending on the user's network connection. And in a production application, it's not acceptable for the entire UI to crash with an unrecoverable error simply because a network request failed or returned an unexpected status code. Code like what I wrote above is far too brittle for real production apps.

An improved version of our hook might look like this:

const useFoo = () => {
    const [isLoading, setIsLoading] = useState(true);
    const [errorMsg, setErrorMsg] = useState('');
    const [foo, setFoo] = useState(null);
    
    useEffect(() => {
        fetch('http://example.com/api/foo')
            .then((res) => {
                // The fetch API does not throw exceptions on HTTP error codes
                if (res.ok) {
                    return res.json();
                }

                throw new Error(res.statusText);
            })
            .then((json) => {
                setFoo(json);
            })
            .catch((err) => {
                setErrorMsg(err.message);
            })
            .finally(() => {
                setIsLoading(false);
            });
    }, []);
    
    return { isLoading, errorMsg, foo };
}

In the improved version of the hook, the app can communicate loading and error information to the user sanely. The components that consume this hook can easily display a loading indicator by tracking isLoading and error information by tracking errorMsg.

There's still a big problem with how we've written this, though...

3. Clean up after your hooks

Network requests are async, which means that they do not block the UI: while our request is in-flight, the user can continue interacting with the rest of the app.

What happens if one of these interactions causes our component to unmount? The short answer is that we would be introducing a memory leak. The Promise that resolves to the server's response is inside a closure, so when the Promise finally resolves, it will still have access to the functions and variables within that scope. However, the component instance these functions and variables were connected to no longer exists. Since there's no mechanism to clean them up, they sit around in memory.

We can prevent this behavior by providing React with a cleanup function. React will invoke this cleanup function any time the component consuming this hook unmounts. Since our hook is using the Fetch API, our cleanup function will have to cancel the fetch request. That would look something like this:

const useFoo = () => {
    const [isLoading, setIsLoading] = useState(true);
    const [errorMsg, setErrorMsg] = useState('');
    const [foo, setFoo] = useState(null);
    
    useEffect(() => {
        // The AbortController will send a signal to the fetch API telling it
        // when a request has been cancelled
        const controller = new AbortController();

        fetch('http://example.com/api/foo', { signal: controller.signal })
            .then((res) => {
                // The fetch API does not throw exceptions on HTTP error codes
                if (res.ok) {
                    return res.json();
                }

                throw new Error(res.statusText);
            })
            .then((json) => {
                setFoo(json);
            })
            .catch((err) => {
                // When a request is cancelled, the fetch API throws an
                // exception with the name "AbortError".
                if (err.name !== 'AbortError') {
                    setErrorMsg(err.message);
                }
            })
            .finally(() => {
                setIsLoading(false);
            });
        
        return function cleanup() {
            // Cancel the fetch request
            controller.abort();
        }   
    }, []);
    
    return { isLoading, errorMsg, foo };
}

Now, when the component is unmounted while a request is in-flight, the request will be cancelled. Since fetch requests throw an exception when they're cancelled, none of the setState functions will be called. This prevents the memory leak.

The fact that this is necessary is not some deep secret of React. The React docs harp on this in their sections on hooks. In fact, if you don't clean up after hooks like this, you'll see console warnings when you run React in dev mode. Pay attention to what your dev tools are telling you!

4. Use unique keys for list items

There's another common mistake I noticed that could be avoided by paying attention to dev tools: rendering lists without unique key props for each list item.

The key prop on list items helps React identify which items (if any) need to re-render when the list changes. Imagine you have a list of 3 items that's rendered on the screen. If you append a 4th item to the end of the list, there's (probably) no reason for the first three list items to re-render unless the list has been re-ordered somehow. If the first 3 list items have unique keys and those keys don't change, React will skip re-rendering them.

Sometimes people will use indices as key values, but this can cause all sorts of problems. What would happen if, instead of appending the new item to the end of the list, we stuck it in the middle, and made our new item the 2nd item in the list? If we were using indices as keys, React would see that the item with index 1 still has key 1: from React's perspective, nothing changed and therefore nothing needs to re-render.

Every item in a list should have a permanent, unique key. This could be some unique property on the items you're rendering (like their database ID). It could even be some kind of hash or client-generated UUID. In our books example, we had:

{books.map(book => (
    <tr key={book.id}>
        <td>{book.title}</td>
        <td>{book.author}</td>
        <td>{book.genre}</td>
        <td>{book.avgRating}</td>
    </tr>
))}

Again, this is something that React will complain about in console output if you skip it. Pay attention to your dev tools!

5. Write code, not boilerplate

My last piece of advice is to make sure that you understand the code you're writing instead of just regurgitating boilerplate. I saw a lot of people writing nonsense code and making conceptual errors because they treated writing React like some kind of dark ritual: you chant a few stock phases, go through the motions, and somehow magic happens in response.

For example, many people would use the React.FC type for components that had no children:

const ChildlessComponent = (): React.FC => (
    <p>React.FC lets you pass children as a prop, but I don't render any!</p>
)

This is just a function that takes no arguments and returns some JSX. It's not just that React.FC is overkill, it actively introduces a potential bug: React.FC gives your component a generic type for the children prop, so now this component's type signature says that it accepts children even though passing it children is a no-op.

Another strange example I saw was a candidate who used async IIFEs everywhere. For example, they'd write something like:

const foo = () => {
    (async function() {
        // ...do stuff
    })()
}

When they could just write:

const foo = async () => {
    // ...do stuff
})

When I asked the candidate about this, it became clear to me that they didn't really understand what the arrow function syntax meant. They were just parroting it back as boilerplate because they'd seen other people using arrow functions without understanding what they were or why you'd use one. I would've been much happier if they'd just used the function syntax they were more comfortable with than incorrectly using syntax they didn't understand.

And that's my closing message to you. Interviews -- especially for a specialist role like this -- are a chance for you to show off the things you know and understand. It's not about just writing some "stuff" that maybe does the right thing if you squint, it's about demonstrating to your interviewer that you have some level of mastery over the technologies and ideas you'll be working with. And mastery means deep understanding.

For the love of God, please, I'm begging you: read the React docs.