Senior Frontend Interview - Debug and Refactor a Real Product Component
7 min read

During my 7 years specializing in Frontend development — and 13 years of Software Engineering overall — I've participated in quite a few technical interviews, both as an interviewer and as a candidate.
I've experienced interviews lasting less than 30 minutes, "LeetCode style" sessions focused on algorithms and JavaScript fundamentals, and others that grilled candidates for more than two hours.
The truth is, like most things in life, there's no perfect way to evaluate engineers.
This article is part of a series I plan to write regarding Senior Frontend Engineer interviews. Most, if not all scenarios, will be based on real interviews I either conducted myself or participated in as candidate.
My goal is to help both candidates and interviewers to prepare more efficiently, whether that means landing the next dream role or learning how to evaluate candidates in a more honest, efficient and meaningful way.
The Debug and Refactor style of exercise strikes an excellent balance between technical evaluation and real-world scenarios. Beyond simply testing whether someone can write code, it reveals how candidates reason about architecture, debugging, performance, communication, code quality, and trade-offs under realistic conditions.
Also, unlike purely algorithmic exercises, this format often feels much closer to the kind of problems Frontend engineers actually face in production environments: review, refactor, polish and optimize code, aimed at senior level judgment.
The Enunciate
A "Debug and Refactor" kind of enunciate would look like the following:
"Users complain that the UI feels slow, search behaves inconsistently, API requests are duplicated, and typing freezes occasionally. Debug and refactor the component."
Then, the interviewer provides the following React code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44import { useEffect, useState } from "react"; type User = { id: number; name: string; }; export default function UserSearch() { const [query, setQuery] = useState(""); const [users, setUsers] = useState<User[]>([]); const [filtered, setFiltered] = useState<User[]>([]); useEffect(() => { fetch("https://jsonplaceholder.typicode.com/users") .then((res) => res.json()) .then((data) => { setUsers(data); }); }, [query]); useEffect(() => { const result = users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()) ); setFiltered(result); }, [query, users, filtered]); return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search users..." /> {filtered.map((user, index) => ( <div key={index}> <p>{user.name}</p> </div> ))} </div> ); }
At first glance, this looks like a small component. In reality, it contains several classic production issues, and most of them quite important honestly.
This exercise is aimed to be solved in less than 30 minutes.
For Candidates:
As always, before diving into code, read carefully the enunciate and think about how to approach the challenge. I suggest to follow by briefly explaining the interviewers what you'll going to do next. This gives you a series of benefits:
will help you feel more grounded by mentally clarifying the next steps
will make sure you correctly understood the enunciate
will shift your role from "coder" to a collaborative software engineer
Remember to speak out loud while coding, to not make long silences, and ask for clarification if something's not clear.
It's often not allowed to use AI coding tools, but it surely is to google for documentation — do it naturally as if you would do in your daily job, but don't abuse it.
One last thing: keep things simple, don't over-engineer.
For Interviewers:
This component is a great example for this kind of exercise as it includes some of the most common refactoring and performance issues related to React: some are very obvious, others require a more analytical thinking.
With only a "debug and refactor as you see fit" as requirement, the more experienced candidate will go quickly over fundamentals, while others may get struck early on.
As always, remember to take in consideration the stress of the interview, in a way or the other.
Identifying the Problems
As mentioned above, a strong interview answer does not start with coding, but with analysis.
Here are the major issues I identified:
1. Infinite/Repetitive Re-Rendering
1 2 3 4useEffect(() => { ... setFiltered(result); }, [query, users, filtered]);
The effect depends on filtered, while also updating filtered, creating unnecessary re-executions.
This itself is an abomination and needs to be spotted immediately, otherwise signaling the candidate is not aware of React fundamentals.
The refactored version would get rid of useEffect entirely and use useMemo to create a memoized derived state, avoiding the infinite-loop problem:
1 2 3 4 5const filteredUsers = useMemo(() => { return users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()) ); }, [users, query]);
2. Derived State Stored Unnecessarily
As per the edits above, the filtered local state is not necessary anymore, and can be deleted:
1 2// this line below can be deleted entirely const [filtered, setFiltered] = useState<User[]>([]);
In the majority of cases, derived states are simply computed properties.
3. Duplicate API Requests
1 2 3useEffect(() => { fetch(...) }, [query]);
The fetch effect depends on query. This means every keystroke triggers a new network request.
This alone explains duplicated API calls, inconsistent behavior and UI sluggishness. An intermediate candidate would spot this early on.
Without over-engineering, the easy, simple fix would be to just remove query from the dependency array:
1 2 3useEffect(() => { fetch(...) }, []); // removing "query" from the dependency array
4. Bad React Keys
1key={index}
Using array indexes as keys can produce subtle rendering bugs when reordering, filtering or inserting/removing items.
The ONLY valid case where an index can be used as key for a list is when knowing with absolute truth the list order would never change. In any case, stable, unique IDs should always be preferred:
1 2 3 4 5{filteredUsers.map(user => ( <div key={user.id}> <p>{user.name}</p> </div> ))}
5. No Loading nor Error State
The component assumes network requests always succeed and data arrives instantly. This is false in the majority, if not all, real-case scenarios.
Real production UIs require:
loading states
error handling
retry strategies
With efficiency and without over-engineering, here's what I mean:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); ... useEffect(() => { async function fetchUsers() { try { setLoading(true); const response = await fetch( "https://jsonplaceholder.typicode.com/users" ); if (!response.ok) { throw new Error("Failed to fetch users"); } const data = await response.json(); setUsers(data); } catch (err) { setError("Something went wrong"); } finally { setLoading(false); } } fetchUsers(); }, []); ... if (loading) { return <p>Loading...</p>; } if (error) { return <p>{error}</p>; } return ( <div> ... </div> ); }
Refactoring the Component
The first round of improvements mentioned above are aimed to solve important React issues, like infinite loops and unnecessary re-renders, as well as providing a basic component architecture by providing a loading state and errors handling.
The final refactored version would look like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69import { useEffect, useMemo, useState } from "react"; type User = { id: number; name: string; }; export default function UserSearch() { const [query, setQuery] = useState(""); const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { async function fetchUsers() { try { setLoading(true); const response = await fetch( "https://jsonplaceholder.typicode.com/users" ); if (!response.ok) { throw new Error("Failed to fetch users"); } const data = await response.json(); setUsers(data); } catch (err) { setError("Something went wrong"); } finally { setLoading(false); } } fetchUsers(); }, []); const filteredUsers = useMemo(() => { return users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()) ); }, [users, query]); if (loading) { return <p>Loading...</p>; } if (error) { return <p>{error}</p>; } return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search users..." /> {filteredUsers.map((user) => ( <div key={user.id}> <p>{user.name}</p> </div> ))} </div> ); }
The biggest improvement is not performance, but simplicity. The component now has:
a single source of truth
predictable data flow
fewer renders
fewer synchronization bugs
And everything is set for a follow-through discussion on in-depth performance optimization, which we'll go through in the next section.
For an intermediate candidate seniority, this may be the bare minimum acceptable for such a "Debug and Refactor" interview.
Production-Level Improvements
In a real application, I would definitely go further.
The next series of improvements are mostly architectural, organizational and performance based — the ones expected at senior level.
They are perfect arguments for an in-depth talk and may not necessarily be needed to be coded during the interview.
Separation of Concerns
First of all, all code is written in one single component and there's no separation of concerns between Data, Business and Presentation layers.
For this specific case, I would:
move data fetch logic into
/api/fetchUserscreate a reusable hook encapsulating all business logic: it would fetch users under the hood and provide loading and error states, as well as the final
filterdUsersmake the component purely presentational
The final results would be:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17// api/fetchUsers.ts export type User = { id: number; name: string; }; export async function fetchUsers(): Promise<User[]> { const response = await fetch( "https://jsonplaceholder.typicode.com/users" ); if (!response.ok) { throw new Error("Failed to fetch users"); } return response.json(); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50// hooks/useUsers.ts import { useEffect, useMemo, useState } from "react"; import { fetchUsers, type User } from "@/api/fetchUsers"; type UseUsersType = { filteredUsers: User[]; loading: boolean; error: string | null; query: string; setQuery: React.Dispatch<React.SetStateAction<string>>; }; export function useUsers(): UseUsersType { const [query, setQuery] = useState(""); const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { async function loadUsers() { try { setLoading(true); const data = await fetchUsers(); setUsers(data); } catch (err) { setError("Something went wrong"); } finally { setLoading(false); } } loadUsers(); }, []); const filteredUsers = useMemo(() => { return users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()) ); }, [users, query]); return { filteredUsers, loading, error, query, setQuery, }; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36// components/UsersList.tsx import { useUsers } from "@/hooks/useUsers"; export default function UsersList() { const { filteredUsers, loading, error, query, setQuery, } = useUsers(); if (loading) { return <p>Loading...</p>; } if (error) { return <p>{error}</p>; } return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search users..." /> {filteredUsers.map((user) => ( <div key={user.id}> <p>{user.name}</p> </div> ))} </div> ); }
Debounced Search
For large datasets or server-side filtering, debouncing is a technique used to prevent expensive work on every keystroke. A senior level candidate would probably mention it quite early on during the 30 minutes time frame.
In our case, by debouncing the fetchUsers call we avoid fetching data on each character input, dramatically reducing the network load.
Remember to debounce the right method: by mistakenly debouncing setQuery we would cause the whole search input to lag.For a production-grade application, I'd usually extract the debounce logic into a reusable hook:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16// hooks/useDebounce.ts import { useEffect, useState } from "react"; export function useDebounce<T>(value: T, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timeout = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timeout); }, [value, delay]); return debouncedValue; }
1 2 3 4 5 6 7const [inputValue, setInputValue] = useState(query); const debouncedQuery = useDebounce(inputValue, 300); useEffect(() => { setQuery(debouncedQuery); }, [debouncedQuery, setQuery]);
React Query / TanStack Query
To expand even further the above examples, I would use an advanced data-fetch library like TanStack Query.
This would avoid having to manually manage caching, loading state, retries and request duplication:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36// hooks/useUsers.ts import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { fetchUsers, type User } from "@/api/users"; export function useUsers() { const [search, setSearch] = useState(""); const { data: users = [], isLoading, isError, error, refetch, } = useQuery<User[]>({ queryKey: ["users"], queryFn: fetchUsers, staleTime: 1000 * 60 * 5, // 5 minutes }); const filteredUsers = useMemo(() => { return users.filter((user) => user.name.toLowerCase().includes(search.toLowerCase()) ); }, [users, search]); return { search, setSearch, filteredUsers, isLoading, isError, error, refetch, }; }
Virtualized Rendering
If the list contains thousands of users, libraries like react-window and TanStack Virtual can dramatically reduce rendering cost.
The windowing technique works by actually rendering only a small subset of the items (= window), reducing the expensive cost of manipulating thousands of DOM elements to a few items only: memory is not expensive, DOM manipulation is.
Accessibility
Accessibility is increasingly part of senior frontend interviews, so an argument worth to mention.
Even for a relatively small component like this one, there are several improvements I would make in a production environment:
associate the search input with a proper
<label>provide semantic roles and landmarks
ensure keyboard navigability
expose loading and error states to screen readers via aria-live
maintain sufficient color contrast and focus visibility
validate responsive behavior across devices and zoom levels
Testing Strategy
From a testing perspective, I would probably approach this component through unit and integration layers, avoiding expensive end-to-end tests for such a small code:
unit tests for filtering logic and hooks
integration tests for user interactions and async behavior
End-to-end testing may be mentioned, but for such a small component it is definitely an overkill, and I would avoid. They are more suited for complex, critical user-flows, like login/signup or CRUD on product / users.
Conclusions
In my opinion, what makes this kind of interview exercise interesting is that the "correct" solution is usually not the point, but it's all the arguments around that make valid candidates stand out.
It simulates a real world scenario, real collaboration — candidate and interviewers somehow become peers — and enforces not only memory coding but architectural thinking aimed to performance and scalability.
The real evaluation then happens not only around identifying problems, but also on communicating trade-offs, structuring responsibilities and balancing scalability with simplicity (= do not over-engineer).