Clean Code, Refactoring, and Maintainability Questions
Writing code that other people can read, change, and keep alive over time: naming, function and module decomposition, avoiding duplication, readability, disciplined use of language idioms and design patterns, and recognizing code smells, extending into working effectively in large, aging, or unfamiliar codebases through safe incremental change, refactoring under test coverage, and managing technical debt. Covers both authoring professional-grade code beyond mere correctness and improving code you cannot rewrite without breaking it. Spans the coding-round quality signal and the seniority signal of leaving a codebase healthier than you found it.
Find and fix the memory leak in a React hook example (or a component that attaches an event listener/subscription and never cleans it up). Explain why the leak happens and how you'd detect it in production before a user reports it.
Sample Answer
Direct answer. The leak is a subscription/listener/effect that's set up but never torn down; fix it by returning a cleanup function from the effect that undoes exactly what the effect set up, so every mount is matched by an unmount -- and detect it in production by watching listener counts or memory growth over repeated navigation, not just by code review.
Before (leaks)
function useWindowWidth(win, setWidth) {
const onResize = () => setWidth(win.innerWidth);
win.addEventListener('resize', onResize);
// no cleanup returned -- every mount adds a NEW listener that's never removed
}
I reproduced this with a minimal harness simulating React's mount/unmount effect lifecycle: after 3 mount/unmount cycles, 3 listeners remained registered on the fake window object -- each unmount left its listener behind, and the count grows unbounded with every remount (e.g., navigating to and from a screen repeatedly).
After (fixed)
function useWindowWidth(win, setWidth) {
const onResize = () => setWidth(win.innerWidth);
win.addEventListener('resize', onResize);
return () => win.removeEventListener('resize', onResize); // cleanup matches the setup exactly
}
Same harness, same 3 mount/unmount cycles: 0 listeners remained -- each unmount's cleanup correctly removed exactly the listener its own mount had added.
Why the leak happens
React's effect lifecycle contract is: whatever an effect RETURNS is treated as its cleanup, run before the next effect execution or on unmount. If nothing is returned, React has no way to know a subscription needs undoing -- the listener silently persists, referencing a setWidth closure tied to a component instance that's already been torn down, which both leaks memory AND (if the closure still fires) can call setState on an unmounted component.
Detecting this in production before a user reports it
- Track a metric for 'active listeners/subscriptions' (if your app has a central event bus or can instrument
addEventListenercalls) and alert if it grows monotonically rather than staying roughly flat during normal navigation. - Use browser dev tools' memory profiler (heap snapshots taken before and after repeated navigate-away-and-back cycles) in a pre-release QA pass specifically targeting screens with subscriptions -- a growing heap across identical repeated actions is the classic leak signature.
- Add a lint rule (
eslint-plugin-react-hooks's exhaustive-deps and related checks) that flags effects with side effects (subscriptions) that don't return a cleanup function, catching the CLASS of bug before it ships at all, not just this instance.
Trade-offs and pitfalls
- Not every effect needs cleanup (a one-time API fetch with no subscription has nothing to unsubscribe from) -- the rule is specifically 'if the effect creates something with an ongoing lifetime (a listener, a timer, a subscription), it must also return how to tear that down,' not 'every effect needs a cleanup function.'
- A cleanup function that references stale closure variables (captures an old value of a dependency) is its own subtle bug distinct from the leak itself -- verify the cleanup uses the SAME reference it set up with, not a value from a later render.
Design a progressive-enhancement strategy for a public-facing app that must function with JavaScript disabled or on a poor connection. Specify which features must work purely server side, how you would structure server-rendered markup and forms, how you would progressively enhance with client-side JavaScript (for example partial hydration or islands), the SEO implications, and the tests you would add to verify graceful degradation.
Sample Answer
Direct answer
Progressive enhancement means the core functionality of a page works from server-rendered HTML alone, with no client-side JavaScript required, and JavaScript is layered on top afterward to improve the experience for browsers and connections that can support it; the design principle is to build the baseline first and treat JavaScript as an enhancement, never as a requirement for the page to function at all.
Structured elaboration
What must work server-side. Any core user action, most importantly form submission, must work as a standard HTML form POST to a server endpoint that returns a full page (or a redirect), with no dependency on client-side JavaScript intercepting the submit event. Navigation must work through real <a href> links that the server can resolve, not exclusively through client-side routing that requires JavaScript to have loaded and executed first.
Structuring server-rendered markup and forms. Forms use native HTML validation attributes (required, type="email") as a first line of defense that works with zero JavaScript, and the server independently re-validates everything on submission regardless, since client-side validation of any kind, HTML-native or JavaScript, can always be bypassed. The markup itself should be semantically structured (real <form>, <button type="submit">, real headings) so it is both accessible and immediately usable without any enhancement layer.
Progressively enhancing with client-side JavaScript. Once the page has loaded and JavaScript has executed, it can intercept the same form's submit event to do an AJAX submission instead, show inline validation before the user submits, or swap in a richer, partially-hydrated interactive component (the islands or partial-hydration pattern) around a specific piece of the page, without ever removing the underlying working form as a fallback if the JavaScript fails to load or execute for any reason.
SEO implications. Search engine crawlers historically executed JavaScript unreliably or not at all, and even where a crawler can execute JavaScript, server-rendered content is indexed faster and more reliably; a page whose core content only appears after a client-side JavaScript render risks being indexed as empty or with a significant delay, which is a second, independent reason (beyond resilience to JS failures) to make sure meaningful content is present in the initial server response.
Testing graceful degradation. A specific, repeatable test disables JavaScript entirely (most browser testing tools and frameworks support this directly) and re-runs the core user flows (can you still submit the form, can you still navigate to another page); this should be a standing check in the test suite, not a one-time manual verification, since a future change can easily reintroduce a JavaScript dependency for something that used to work without it.
Worked example
A newsletter signup form: the server renders a real <form method="POST" action="/subscribe"> containing an <input type="email" required> and a submit button. With JavaScript disabled entirely, submitting this form performs a full page POST to /subscribe, the server validates the email server-side, and returns either a success page or a re-rendered form with an inline error message, and the user has successfully subscribed with zero JavaScript involved. With JavaScript enabled, a script intercepts the same form's submit event, performs the same request via fetch instead of a full page navigation, and swaps in a small success message without a full page reload, a nicer experience layered on top of, not replacing, the working baseline. If the JavaScript bundle fails to load (a content delivery network (CDN) outage, an ad-blocker interference, a slow connection that times out the script fetch), the form still works exactly as it did in the no-JS case, since the interception was purely additive.
Trade-offs and pitfalls
Progressive enhancement takes genuinely more implementation effort than building a JavaScript-only single-page interaction, since the team is effectively building and maintaining two paths (the server-rendered baseline and the JavaScript enhancement) rather than one; this cost is worth paying for core flows (checkout, signup, anything revenue- or conversion-critical) and is often not worth paying for a genuinely optional, decorative interactive widget where a JavaScript-only implementation is a reasonable, deliberate trade-off. The most common mistake is building the JavaScript-enhanced version first and then treating the no-JS fallback as an afterthought bolted on at the end, which reliably produces a fallback that technically exists but was never really tested and quietly breaks the first time the enhanced version's markup changes.
Write a TypeScript function parseUserResponse(response: any): User that validates an external API payload and returns a typed User. It must validate the required fields id: number, name: string, and email: string, normalize email to lowercase, and throw a typed InvalidResponseError with details when validation fails. Include the User and InvalidResponseError type definitions and a brief rationale for your approach.
Sample Answer
Direct answer
Parsing an external API response into a typed domain object means validating every field the type promises, normalizing the ones that need it, and failing with a specific, typed error rather than returning a value that merely satisfies the compiler's type system without actually matching it at runtime, since TypeScript's types disappear at runtime and provide zero protection against a response that lies about its own shape.
Structured elaboration
Why any at the boundary is the point, not a bug. The function's input is typed any deliberately: an external API response has no compile-time guarantee at all, it is just bytes that were JSON-parsed. The function's entire job is to be the one place where that untyped data gets checked against reality before anything downstream is allowed to trust it as a User.
Field-by-field validation with a typed, informative error. Each required field is checked for both presence and correct runtime type. A custom InvalidResponseError (rather than a generic Error) lets calling code distinguish "the API sent us garbage" from any other kind of failure, and carries a details array so a caller (or a log line) can see exactly which fields were wrong, not just that something was wrong.
Normalization as part of the contract. Lowercasing email here is a deliberate normalization step that belongs in the parser, not scattered across every place that later compares emails, so two calls to this function are guaranteed to return comparably-normalized data.
Single responsibility and testability. This function does exactly one thing (turn any into a valid User or throw), which makes it trivial to unit test in isolation with a table of valid and invalid payloads, and it can be reused anywhere a User needs to be parsed from an external source without duplicating the validation logic at each call site.
Worked example
class InvalidResponseError extends Error {
details: string[];
constructor(details: string[]) {
super(`Invalid user response: ${details.join('; ')}`);
this.details = details;
this.name = 'InvalidResponseError';
}
}
interface User {
id: number;
name: string;
email: string;
}
function parseUserResponse(response: any): User {
const details: string[] = [];
if (response === null || typeof response !== 'object') {
throw new InvalidResponseError(['response is not an object']);
}
if (typeof response.id !== 'number' || !Number.isFinite(response.id)) {
details.push('id must be a number');
}
if (typeof response.name !== 'string' || response.name.trim().length === 0) {
details.push('name must be a non-empty string');
}
if (typeof response.email !== 'string' || !response.email.includes('@')) {
details.push('email must be a valid-looking string');
}
if (details.length > 0) throw new InvalidResponseError(details);
return { id: response.id, name: response.name, email: String(response.email).toLowerCase() };
}
Executed (TypeScript, strict mode, verified): parseUserResponse({ id: 7, name: 'Ada', email: 'Ada@Example.COM' }) returns {id: 7, name: 'Ada', email: 'ada@example.com'}. parseUserResponse({ id: 'not-a-number', name: '' }) throws InvalidResponseError whose details array has exactly 3 entries (id, name, and email are all invalid or missing). parseUserResponse(null) throws the same error type with a single 'response is not an object' detail, confirming the object-shape guard runs before any field-level check would otherwise throw a raw TypeError trying to read .id off null.
Trade-offs and pitfalls
Hand-rolled field checks like this do not scale gracefully to a User with twenty fields; at that point a schema library (zod, io-ts) that generates the same checks from a declarative schema is worth the dependency, since duplicating this pattern by hand for every field invites the classic bug of forgetting to validate one of them. The single biggest pitfall this pattern exists to prevent: a bare type assertion like return response as User compiles cleanly and provides absolutely no runtime protection, silently passing a malformed object downstream as if it had been checked. A second common mistake is validating response.email as a string but never guarding the case where response itself is null, which throws a raw, unhelpful TypeError before your own validation code even runs, which is exactly why the object-shape check comes first in this implementation.
Plan a migration of a large React codebase from class components to functional components with hooks. What would you automate (codemods) versus convert by hand, and how do you sequence it so the app stays shippable throughout?
Sample Answer
Direct answer. Automate the mechanical transformation (codemods) for the boilerplate that has a predictable one-to-one mapping, and convert by hand anything with real behavioral subtlety (lifecycle timing, this-binding, complex state interactions) -- verified component-by-component behind snapshot/behavioral tests, never as one big-bang rewrite.
What to automate with codemods
- Converting
this.state/this.setStateboilerplate intouseStatecalls for SIMPLE, single-piece-of-state components has a fairly mechanical mapping a codemod (e.g., usingjscodeshift) can handle reliably. - Converting straightforward lifecycle methods with a clean 1:1 mapping (
componentDidMountwith no cleanup -> auseEffectwith an empty dependency array) is also automatable for the common case.
What needs manual conversion
componentDidUpdatewith complex conditional logic comparing multiple previous props/state doesn't map cleanly to a singleuseEffect-- the dependency array semantics are different enough (effects re-run based on VALUE comparison of dependencies, not an explicitprevPropscheck) that automated conversion risks silently changing WHEN the effect fires.- Class components combining
componentDidMountANDcomponentWillUnmountfor setup/teardown of the same resource need careful manual conversion into a singleuseEffectwith a cleanup function, since getting the closure/dependency semantics wrong here is exactly the class of subtle bug (the stale-closure and cleanup-leak issues covered earlier in this topic). - Components using
thisfor imperative escape hatches (refs to child components, calling a method onthisfrom an event handler) need careful handling since hooks change how refs and callbacks are typically structured.
Sequencing to stay shippable throughout
- Start with LEAF, presentational components with no lifecycle methods and simple state -- pure conversion, easiest to automate and verify.
- Move to components with straightforward, single-effect lifecycle usage -- automate what's safe, hand-verify the rest.
- Save the most complex, stateful, multi-effect components for last, once the team has built confidence and identified the recurring subtle patterns from earlier conversions.
- At every step, keep a snapshot/behavioral test (render output, interaction behavior) passing before AND after conversion for that specific component, so regressions are caught locally rather than discovered app-wide later.
Trade-offs and pitfalls
- A codemod that 'mostly works' but silently mishandles the trickiest 10% of cases is dangerous precisely because the OUTPUT still compiles and often still looks reasonable -- always manually review codemod output for lifecycle-heavy components rather than trusting a clean diff at a glance.
- Migrating a component's TESTS along with the component itself (rather than leaving old enzyme-style tests running against a hooks-based component) avoids a false sense of safety from tests that no longer meaningfully exercise the new implementation's actual behavior.
An API your frontend relies on changed its response shape unexpectedly. Describe strategies to handle API contract mismatches robustly on the frontend: runtime schema validation (for example with zod or io-ts), defensive parsing, feature detection and fallbacks, graceful-degradation UIs, and observability to surface the mismatch. Discuss the trade-offs between runtime guards and strict compile-time types, and how you would coordinate the fix with the backend team.
Sample Answer
Direct answer
When an upstream API changes its response shape unexpectedly, the frontend needs to detect the mismatch at runtime (not just trust its compile-time types), fail gracefully into a degraded but still-usable UI state rather than crashing, and surface the mismatch to observability so the team learns about the drift quickly rather than from a user complaint days later.
Structured elaboration
Runtime schema validation. A library like zod or io-ts validates the actual shape of an API response at the moment it arrives, distinct from TypeScript's compile-time types, which provide no protection whatsoever against a response that no longer matches what the type declares; parsing every external response through a runtime schema (UserSchema.parse(response)) means a shape mismatch is caught immediately, at the boundary, with a specific error identifying which field was unexpected, rather than surfacing later as a confusing crash deep inside a component that assumed a field existed.
Defensive parsing and fallbacks. Where a field is optional or was recently added to the API and might not yet be present in all responses (during a rollout, for example), defensive parsing accepts its absence and substitutes a sensible default rather than treating every deviation as a hard failure; this needs to be a deliberate choice, since a runtime schema validator can be configured either to reject any unrecognized shape strictly or to accept a superset and log what it didn't expect, and the right choice differs by field.
Feature detection over version checking. Where possible, check for the actual presence of a field or capability the code needs (if (response.newField !== undefined)) rather than trying to detect which "API version" produced this response, since feature detection degrades more gracefully as the API evolves incrementally and does not require the frontend to maintain a mapping of exactly which version introduced which field.
Graceful-degradation UI. When a validation failure does occur, the UI should fall back to a reduced but still-functional state (show what data IS available and valid, omit or placeholder what isn't) rather than a blank page or an uncaught-exception error boundary firing for what is really a data-contract problem, not a rendering bug.
Observability to surface the mismatch. Every runtime schema-validation failure should be logged to an error-tracking or monitoring system with the specific validation error (which field, what was expected versus received), tagged distinctly from other JavaScript errors, so the team notices a contract drift as an aggregate trend ("validation failures for field X started spiking at time Y") rather than discovering it from an isolated user bug report days later.
Coordinating with the backend team. A schema-drift monitoring signal is most useful when it is specifically actionable: routing it to the team that OWNS the API in question, ideally tied to their deploys, so a shape change introduced in a specific backend release can be traced back to that release quickly, rather than becoming a frontend-only investigation into a problem the frontend team cannot actually fix at its root.
Worked example
An API that used to return { user: { name, email } } changes, without notice, to { user: { name, email, verified } }, and separately, in a different deploy, silently starts returning email: null for a subset of enterprise accounts instead of a string. Runtime schema validation with zod, configured to accept unknown additional fields (.passthrough()), does not break on the new verified field appearing; the frontend simply ignores a field it doesn't yet use. The email: null change, however, DOES fail the schema (email: z.string()), which is caught immediately, logged to the error-tracking service tagged schema_validation_failure with the field name and account type, and the profile component renders with a degraded state ("Email unavailable") for that specific field rather than crashing the whole page. The logged event, filtered by account type, quickly surfaces that this is isolated to enterprise accounts, which is handed to the backend team as a specific, actionable report rather than a vague "something broke" ticket.
Trade-offs and pitfalls
Strict runtime validation that rejects ANY unrecognized field (rather than passing through additive changes) turns every routine, backward-compatible API addition into a frontend outage, which is usually the wrong default; the common mistake is configuring schema validation as strictly as possible "for safety" without considering that additive changes are supposed to be safe and should not trip the same alarm as a genuinely breaking change like a field changing type. The opposite mistake, validating nothing at runtime and trusting TypeScript's compile-time types alone, means a shape change surfaces as an uncaught runtime exception in whatever component first touches the missing or wrong-typed field, with no specific signal pointing at the actual API contract as the root cause.
Unlock Full Question Bank
Get access to all 23 Clean Code, Refactoring, and Maintainability interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.