Future-Proof Components
Overview
Most components are written for one page, and they work there. The problems start when the component travels: it gets rendered on a server, mounted twice on the same screen, moved into another window, or run under a React feature that did not exist when it was written.
This article takes one component and hardens it step by step. The example is a theme provider, the component at the top of the tree that decides whether the app renders light or dark and remembers your choice. Each section puts the provider in a new situation, shows how it breaks, and fixes it.
A component is finished when it works on pages you have never seen, not when it works on yours.
Server-proof
The provider reads the saved theme from localStorage:
function ThemeProvider({ children }) { const [theme, setTheme] = useState( localStorage.getItem('theme') || 'light' ); return <div className={theme}>{children}</div>;}But localStorage does not exist on the server. SSR frameworks like Next.js run your component on a server first to produce ready HTML, so this line throws and the whole page fails.
Move browser-only APIs into useEffect; effects run only in the browser:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); useEffect(() => { setTheme(localStorage.getItem('theme') || 'light'); }, []); return <div className={theme}>{children}</div>;}The server renders the light theme, and the browser corrects it right after.
Hydration-proof
The server-proof version has a visible problem: the server does not know what is in localStorage, so it always renders light. Effects run only after hydration (React attaching to the server's HTML), so a user who picked dark sees a light flash on every load.
React runs too late to fix this. Inject a small plain script that sets the class before the browser paints:
function ThemeProvider({ children }) { const script = ` try { const theme = localStorage.getItem('theme') || 'light'; document.getElementById('theme').className = theme; } catch (e) {} `; return ( <> <div id="theme">{children}</div> <script dangerouslySetInnerHTML={{ __html: script }} /> </> );}By the time the browser paints, the class is already correct: no flash, no mismatch. The try/catch covers privacy modes where localStorage can throw.
Instance-proof
The hydration-proof version targets a hardcoded id="theme". But what happens if someone uses two theme providers? There is only one id.
function App() { return ( <> <ThemeProvider> <MainApp /> </ThemeProvider> <ThemeProvider> <ThemePreview /> </ThemeProvider> </> );}Both scripts fight over the same element.
Use useId to generate stable, unique IDs per instance:
function ThemeProvider({ children }) { const id = useId(); const script = ` try { const theme = localStorage.getItem('theme') || 'light'; document.getElementById('${id}').className = theme; } catch (e) {} `; return ( <> <div id={id}>{children}</div> <script dangerouslySetInnerHTML={{ __html: script }} /> </> );}Each copy now targets its own element.
Concurrent-proof
So far the saved theme lives in one browser. To follow the user across devices, move it to the server: a Server Component (it runs only on the server, so it can query the database directly) reads the preference.
async function ThemeProvider({ children }) { const prefs = await db.preferences.get(userId); return <div className={prefs.theme}>{children}</div>;}async function TopBar() { const prefs = await db.preferences.get(userId); return <header className={prefs.theme} />;}Two readers, two identical queries per page load. React does not promise to render components once or in a fixed order, so you cannot coordinate this by hand.
React's cache wraps a function so that, within a single request, repeated calls with the same argument share one result:
import { cache } from 'react';const getPreferences = cache((userId) => db.preferences.get(userId));The database sees one query per request, no matter how many components ask.
Composition-proof
Children need the current theme as a prop. One old way is cloneElement, which copies each child with extra props:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); return React.Children.map(children, (child) => React.cloneElement(child, { theme }) );}But a child can be a Server Component, a lazy component, or an async component: an opaque object or even a Promise, not an element with props. cloneElement fails, and so does every pattern built on reading children.
Context passes a value down without touching the children at all:
const ThemeContext = createContext('light');function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); return ( <ThemeContext.Provider value={theme}> <div className={theme}>{children}</div> </ThemeContext.Provider> );}function ChartAxis() { const theme = useContext(ThemeContext); const stroke = theme === 'dark' ? '#eee' : '#111'; return <line y2="100%" stroke={stroke} />;}The children pass through untouched; the ones that care read the theme from context at any depth.
Portal-proof
Time to add the keyboard shortcut. Cmd+D toggles dark mode:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); useEffect(() => { const onKeyDown = (e) => { if (e.metaKey && e.key === 'd') { e.preventDefault(); setTheme((t) => (t === 'dark' ? 'light' : 'dark')); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, []); return <div className={theme}>{children}</div>;}But render the provider in a pop-out window (chat apps do this with createPortal) and the shortcut goes dead: window still points at the main window, so the listener sits where nobody is typing.
Ask the DOM instead of trusting the global. Every element knows which document it belongs to (ownerDocument), and every document knows its window (defaultView):
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const ref = useRef(null); useEffect(() => { const win = ref.current?.ownerDocument.defaultView || window; const onKeyDown = (e) => { if (e.metaKey && e.key === 'd') { e.preventDefault(); setTheme((t) => (t === 'dark' ? 'light' : 'dark')); } }; win.addEventListener('keydown', onKeyDown); return () => win.removeEventListener('keydown', onKeyDown); }, []); return ( <div ref={ref} className={theme}> {children} </div> );}The listener now lands on whichever window the provider actually rendered into.
Transition-proof
The theme switch currently snaps. React's ViewTransition animates UI changes with the browser's View Transition API (experimental, imported as unstable_ViewTransition). Wrap the themed tree in it:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggle = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')); return ( <ViewTransition> <div className={theme}> <button onClick={toggle}>Toggle theme</button> {children} </div> </ViewTransition> );}Nothing animates: ViewTransition only animates updates marked as transitions, and a plain state update is urgent. Mark it with startTransition:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggle = () => startTransition(() => setTheme((t) => (t === 'dark' ? 'light' : 'dark')) ); return ( <ViewTransition> <div className={theme}> <button onClick={toggle}>Toggle theme</button> {children} </div> </ViewTransition> );}Now the theme cross-fades. The general rule: new React features key off how you update state, so a component that marks every update as urgent opts out of them without knowing.
Activity-proof
The provider owns the page's colors: THEME_CSS maps each theme to its :root variables, injected in a style tag.
function ThemeProvider({ children }) { const [theme, setTheme] = useState('dark'); return ( <> <style>{THEME_CSS[theme]}</style> {children} </> );}React 19.2 added Activity, which hides part of the UI while keeping its DOM and state alive. Hide a dark view in an Activity and its style tag keeps applying, because display: none does nothing to a stylesheet: the visible view renders on a dark background it never asked for.
Activity runs your effect cleanups when it hides content and re-runs the effects when it shows it again. Tie the stylesheet to an effect and turn it off with media="not all", which matches no situation:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('dark'); const ref = useRef(null); useLayoutEffect(() => { const style = ref.current; style.media = 'all'; return () => { style.media = 'not all'; }; }, []); return ( <> <style ref={ref}>{THEME_CSS[theme]}</style> {children} </> );}When Activity hides the view, the cleanup turns its stylesheet off.
Leak-proof
The theme settings screen shows the signed-in user's saved preferences. A Server Component fetches the user and passes it down:
async function SettingsPage() { const user = await getUser(); return <UserThemeConfig user={user} />;}The user object carries a session token, and UserThemeConfig belongs to another team. If anything in that tree passes user to a Client Component, React serializes it into the page payload and the token is readable in dev tools.
React's taintUniqueValue API (experimental) marks a value as never allowed to reach the client:
import { experimental_taintUniqueValue } from 'react';async function SettingsPage() { const user = await getUser(); experimental_taintUniqueValue( 'Do not send the session token to the client.', user, user.token ); return <UserThemeConfig user={user} />;}Any attempt to pass user.token to a Client Component now fails with your message. taintObjectReference does the same for a whole object.
Future-proof
The last problem is different. generateAccents(baseTheme) derives each theme's accent colors with some randomness, cached with useMemo so they do not change on every render:
function ThemeProvider({ baseTheme, children }) { const accents = useMemo( () => generateAccents(baseTheme), [baseTheme] ); return <div style={accents}>{children}</div>;}But useMemo is a performance hint, not a promise: React may drop the cache and call your function again (hot reload already does). The code treats a cache as storage.
When correctness depends on a value staying put, put it in state:
function ThemeProvider({ baseTheme, children }) { const [accents, setAccents] = useState(() => generateAccents(baseTheme) ); const [prevTheme, setPrevTheme] = useState(baseTheme); if (baseTheme !== prevTheme) { setPrevTheme(baseTheme); setAccents(generateAccents(baseTheme)); } return <div style={accents}>{children}</div>;}State is a promise: the value stays until you change it. Do not rewrite every useMemo as state; do check whether your component survives React dropping a cache under it.
In an interview
No interviewer says "make this portal-proof." The idea shows up indirectly. In a component design round, after you sketch the API, the follow-ups sound like: what happens if this renders on the server, what if two of them mount at once, what if the shortcut fires inside an iframe. This article is those answers.
Every fix in this article removed one assumption. A component becomes future-proof not by predicting React's roadmap, but by assuming less.