Notifyy (notifyy.io) is a WhatsApp Business SaaS platform — a Meta Business Provider building an all-in-one suite for sales, support, and marketing teams. Their core product stack includes:
| Feature Module | What It Is |
|---|---|
| Unified Inbox | Shared multi-agent WhatsApp team inbox with real-time messaging |
| Built-in CRM | Contact/company management, deal pipelines, CSV import |
| Campaign Manager | Bulk WhatsApp broadcasts with audience segmentation |
| Workflow Automation | No-code visual chatbot/automation builder |
| WhatsApp Template Engine | Meta-approved template creation and management |
Tech evidence from the site: The app domain is app.notifyy.io, the site is clearly built on Next.js (_next/image CDN URLs, opengraph-image meta), and their backend is hosted on crm.notifyy.io suggesting a separate CRM API layer. The theme color #003D8A and structured layout suggest a design-system-driven frontend.
Notifyy is a product SaaS startup with a live product and paying customers. They need a developer who can:
❌ Beginner mistake: "I am Gopinath, I have 2 years of experience in React..."
(Too short, no narrative, no hook)
✅ Best answer:
"I'm Gopinath, a Frontend Engineer with 2+ years of production experience building enterprise-grade applications using React.js and Next.js. At Rugaa, I've been the primary frontend developer for two products — Lease Portfolio 365, a financial dashboard handling 10,000+ lease records with optimized rendering, and NamSchool, a school management admin suite. I specialize in high-performance dashboards, complex form-driven workflows, and RBAC systems — which I understand are directly relevant to what Notifyy builds. I use AI tools like GitHub Copilot and ChatGPT as part of my daily workflow to accelerate delivery, and I'm an immediate joiner."
Why this is strong: Opens with impact, ties experience to their product domain, and ends with a practical selling point (immediate joiner + AI fluency).
✅ Best answer:
"I've had great learning at Rugaa — I took ownership of two products end-to-end. But I'm ready to work on a live SaaS product with a larger user base, faster iteration cycles, and more cross-functional exposure. Notifyy builds exactly the kind of product I want to grow with — real-time messaging, CRM, multi-agent workflows — these are engineering problems I find genuinely interesting and haven't fully explored yet."
Red flag to avoid: Never say "salary isn't enough" or criticize your current company.
✅ Best answer:
"I looked at the product carefully before this conversation. Notifyy is a Next.js SaaS platform — I can see that in the architecture. The problem space — combining a CRM, real-time inbox, and campaign automation into one product — is genuinely complex from a frontend perspective. You're dealing with live data, multi-agent state, role-based views, and performance-sensitive tables — these are exactly the challenges I've been solving. The startup stage also means I can contribute to architecture decisions, not just execute tickets."
What this signals: You did your homework. Startup hiring managers love this.
✅ Best answer (Lease Portfolio 365):
"The most technically demanding project was Lease Portfolio 365 — a financial dashboard for a lease management SaaS. The core challenge was rendering and managing 10,000+ lease records. My first approach was naive — we were rendering everything at once and the table was lagging badly. I fixed this in two layers: first, I moved to paginated API calls with controlled state updates so we never load more than needed. Second, for the aggregate views, I applied
useMemoto derived calculations so they didn't recompute on every unrelated state change. I also implemented SSR for the initial data load so the page wasn't blank on first render, which improved both perceived performance and SEO. This brought our dashboard load time down significantly."
Follow-up the interviewer will ask: "How did you measure the performance improvement?"
Your answer: "I used Chrome DevTools Performance tab and React DevTools profiler. I measured component render counts before and after memoization and tracked Time to Interactive through Lighthouse."
✅ Best answer:
"Based on my experience with production-grade Next.js applications, RBAC systems, and dashboard engineering — and considering Notifyy's SaaS product stage — I'm looking at ₹6 to ₹8 LPA. I'm open to discussing based on the full package and growth trajectory."
✅ Strength:
"My strongest skill is performance debugging. When something is slow in production, I know how to isolate whether it's an unnecessary re-render, an expensive computation, a poorly batched API call, or a DOM-level issue. I've done this on real dashboards with 10k+ records, not toy examples."
✅ Weakness (honest + growth-framed):
"I haven't yet worked deeply with TypeScript in production — I've read through it and understand interfaces and generics, but my current projects use JavaScript. I'm actively learning it and have been converting components to TypeScript in a side project. I know it's standard in serious SaaS codebases."
✅ Best answer:
"On NamSchool, there was a disagreement with the backend developer about where validation should live — they wanted server-side only, I argued for client-side too for a better UX. We talked it through and agreed on both: client-side for immediate feedback, server-side as the source of truth for security. That's now our standard pattern. The key was separating the 'what's right for the user' question from personal preference."
✅ Production-level answer:
"A closure is when a function retains access to variables from its outer scope even after the outer function has returned. A very common real-world issue: event listeners inside loops."
// Bug — all listeners log the same value (i = 5 after loop ends)
for (var i = 0; i < 5; i++) {
button[i].addEventListener('click', () => console.log(i));
}
// Fix — use let (block-scoped) OR use a closure via IIFE
for (let i = 0; i < 5; i++) {
button[i].addEventListener('click', () => console.log(i)); // ✅ correct
}
"In React, closures are the reason stale state bugs happen inside
useEffect— the effect captures the value of a variable at the time it was created, not the latest value."
Follow-up: "What is a stale closure in React?"
"When
useEffectcaptures an old value of state or props in its closure, but the component has since re-rendered with new values. Fix: add the variable to the dependency array, or use auseRefto hold a mutable reference."
✅ Answer:
"JavaScript is single-threaded. The event loop is what allows async operations — like API calls or timers — to run without blocking the main thread. It has a call stack, a Web API layer, a callback/task queue, and a microtask queue. Microtasks (Promises) always run before macrotasks (setTimeout)."
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
"For UI performance, this matters because long synchronous operations block rendering. If I have a
forloop processing 50,000 records synchronously, the browser can't paint anything during that time. The fix is to chunk the work usingrequestAnimationFrameorsetTimeoutchunks, or move it to a Web Worker."
✅ Answer:
"Debouncing delays execution until after a pause in events. Throttling limits execution to at most once per time window."
// Debounce — search input: only fire after user stops typing
const debounce = (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
// Throttle — scroll handler: fire at most once every 100ms
const throttle = (fn, limit) => {
let inThrottle;
return (...args) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
};
"At Notifyy, I'd debounce the contact search in the CRM list (fire API only after user pauses). I'd throttle a scroll event listener on a long conversation thread."
✅ Production pattern:
// ❌ Common mistake — forgetting error handling
const fetchContacts = async () => {
const data = await axios.get('/api/contacts');
setContacts(data);
};
// ✅ Production pattern
const fetchContacts = async () => {
try {
const { data } = await axios.get('/api/contacts');
setContacts(data);
} catch (error) {
if (error.response?.status === 401) {
// token expired — redirect to login
router.push('/login');
} else {
setError('Failed to load contacts. Please try again.');
}
} finally {
setLoading(false);
}
};
✅ Answer:
"The most common React memory leak is calling
setStateon an unmounted component — for example, inside auseEffectthat makes an API call, but the user navigates away before it resolves."
// ❌ Memory leak
useEffect(() => {
fetchData().then(data => setData(data)); // component may be gone
}, []);
// ✅ Fix — AbortController
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal })
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort(); // cleanup
}, []);
"In DevTools, I detect this via the Memory tab — take heap snapshots before and after navigation, and look for components that remain in the snapshot after they should be gone."
✅ Production example:
// Scenario: Notifyy campaign list — filter active, transform, then count by status
const campaigns = [
{ id: 1, status: 'sent', delivered: 480, total: 500 },
{ id: 2, status: 'failed', delivered: 0, total: 200 },
{ id: 3, status: 'sent', delivered: 990, total: 1000 },
];
// Get delivery rate for sent campaigns only
const deliveryStats = campaigns
.filter(c => c.status === 'sent')
.map(c => ({
...c,
deliveryRate: ((c.delivered / c.total) * 100).toFixed(1) + '%'
}));
// Aggregate total delivered
const totalDelivered = campaigns.reduce((sum, c) => sum + c.delivered, 0);
✅ Answer:
"React maintains a virtual DOM — a lightweight JavaScript representation of the real DOM. When state changes, React creates a new virtual DOM tree, diffs it against the previous one (this is reconciliation), and only applies the minimal set of real DOM changes needed. The diffing algorithm uses keys to identify list items and avoids re-rendering siblings that haven't changed. This is why keys in lists must be stable and unique — if you use array index as key, React may re-render or mismatch items when the list order changes."
Follow-up: "What triggers a re-render in React?"
"State change, props change, parent re-render (child re-renders even if its own props didn't change — unless wrapped in React.memo), or a context value change."
✅ Answer:
// Mistake 1 — missing dependency (stale closure)
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // always logs 0 — stale closure
}, 1000);
return () => clearInterval(timer);
}, []); // ❌ count missing from deps
// Fix — use functional update or add count to deps
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // ✅ functional update, no stale closure
}, 1000);
return () => clearInterval(timer);
}, []);
// Mistake 2 — object/function as dependency (infinite loop)
useEffect(() => {
fetchData(options); // refetches every render if options is a new object
}, [options]); // ❌ new object reference on every render
// Fix — useMemo or primitive deps
const stableOptions = useMemo(() => ({ page, limit }), [page, limit]);
useEffect(() => {
fetchData(stableOptions);
}, [stableOptions]); // ✅
✅ Answer:
"
useMemomemoizes a computed value.useCallbackmemoizes a function reference. UseuseMemowhen you have an expensive computation that shouldn't re-run on every render. UseuseCallbackwhen you pass a function as a prop to a memoized child component — otherwise the child re-renders because it receives a new function reference every time."
// useMemo — expensive filter on 10,000 records
const filteredLeases = useMemo(() => {
return leases.filter(l => l.status === activeFilter && l.amount > minAmount);
}, [leases, activeFilter, minAmount]);
// useCallback — stable function for memoized child
const handleDelete = useCallback((id) => {
dispatch(deleteContact(id));
}, [dispatch]);
// ❌ DON'T over-memoize — this is wasteful
const name = useMemo(() => user.firstName + ' ' + user.lastName, [user]);
// Just do: const name = user.firstName + ' ' + user.lastName;
Interviewer trap: "Should you always use useMemo and useCallback?"
Answer: "No. They have overhead — memory and comparison cost. Only use them when you can measure a real performance problem, or when passing stable references to memoized children."
✅ Answer:
"Context API is ideal for low-frequency global data — auth state, theme, locale. It's NOT designed for high-frequency updates like a chat inbox where messages arrive every few seconds. Every consumer re-renders when the context value changes, so a busy inbox using context naively would cause massive performance problems. For that, you'd use Redux with selectors that only subscribe to the specific slice you need, or a more modern solution like Zustand."
// ✅ Good Context usage — auth state (rarely changes)
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
return <AuthContext.Provider value={{ user, setUser }}>{children}</AuthContext.Provider>;
};
// ❌ Bad Context usage — real-time messages (updates constantly)
// Use Redux + RTK or a dedicated message store instead
✅ Answer:
"Controlled components store form state in React state — every keystroke updates state, React is the source of truth. Uncontrolled use refs to access DOM values. For complex forms like Notifyy's campaign wizard, I use
react-hook-form— it avoids re-rendering on every keystroke by using uncontrolled inputs internally, while still giving you validation and submission handling."
// react-hook-form — production pattern for multi-step campaign wizard
const { register, handleSubmit, formState: { errors }, watch } = useForm({
defaultValues: { campaignName: '', audience: 'all', template: '' }
});
const onSubmit = async (data) => {
try {
await api.post('/campaigns', data);
toast.success('Campaign created!');
} catch (err) {
toast.error(err.response?.data?.message || 'Error creating campaign');
}
};
// Input with validation
<input
{...register('campaignName', {
required: 'Campaign name is required',
minLength: { value: 3, message: 'Minimum 3 characters' }
})}
/>
{errors.campaignName && <span>{errors.campaignName.message}</span>}
✅ Answer:
"Error boundaries are class components that catch JavaScript errors in their child component tree during rendering, in lifecycle methods, and in constructors. They don't catch errors in event handlers or async code."
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log to your monitoring service (Sentry, etc.)
logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <div className="error-state">Something went wrong. <button onClick={() => this.setState({ hasError: false })}>Try again</button></div>;
}
return this.props.children;
}
}
// Usage — wrap major sections
<ErrorBoundary>
<CampaignDashboard />
</ErrorBoundary>
✅ Answer:
// Lazy load heavy components — e.g., a campaign analytics chart
const CampaignChart = React.lazy(() => import('./CampaignChart'));
function Dashboard() {
return (
<Suspense fallback={<Skeleton height={300} />}>
<CampaignChart />
</Suspense>
);
}
"In Next.js, you also have
next/dynamicwhich gives you more control — you can disable SSR for components that rely on browser APIs."
import dynamic from 'next/dynamic';
const ReactFlowEditor = dynamic(() => import('./WorkflowBuilder'), {
ssr: false, // doesn't work server-side
loading: () => <div>Loading workflow editor...</div>
});
✅ Answer:
"Before React 18, state updates were batched only inside React event handlers. If you called
setStatetwice inside asetTimeoutor a Promise, they'd cause two re-renders. React 18 introduced automatic batching — all state updates, regardless of where they happen, are batched into a single re-render."
// React 18 — both setState calls cause only ONE re-render
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Only 1 re-render now, not 2
}, 1000);
// If you need to opt out (rare):
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1)); // forces immediate re-render
✅ Answer with Notifyy context:
| Strategy | When to Use | Notifyy Example |
|---|---|---|
| SSR (getServerSideProps) | Data changes per request, must be fresh, user-specific | CRM contact page — each user sees their own data |
| SSG (getStaticProps) | Data rarely changes, same for all users | Marketing pages, pricing page |
| ISR | Data changes periodically, not per-request | Blog posts, campaign templates gallery |
| Client-side fetch | Real-time, user-interaction driven | Inbox messages, live conversation updates |
// SSR — user-specific CRM dashboard
export async function getServerSideProps(context) {
const { req } = context;
const token = req.cookies.auth_token;
if (!token) {
return { redirect: { destination: '/login', permanent: false } };
}
const contacts = await fetchContacts(token);
return { props: { contacts } };
}
// ISR — campaign template gallery (revalidate every 10 min)
export async function getStaticProps() {
const templates = await fetchPublicTemplates();
return { props: { templates }, revalidate: 600 };
}
✅ Answer:
"The App Router (Next.js 13+) uses React Server Components by default. Components are server-rendered unless you opt in with
'use client'. This means you can fetch data directly in the component — no need for getServerSideProps. It also introduces nested layouts, loading.js, error.js colocated with routes, and streaming with Suspense."
// App Router — server component (no 'use client')
// app/contacts/page.js
async function ContactsPage() {
const contacts = await fetchContacts(); // runs on server, no useEffect needed
return <ContactList contacts={contacts} />;
}
// Client component for interactivity
'use client';
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
return <input onChange={e => onSearch(e.target.value)} />;
}
✅ Answer:
"Hydration is the process where React attaches event listeners and state to the static HTML that the server already sent. The server renders HTML, the browser receives it and displays it immediately (fast first paint), then React's JS loads and 'hydrates' the static HTML into a live React app. A hydration error occurs when the server-rendered HTML doesn't match what React expects to render on the client — for example, rendering
new Date()on the server produces a different timestamp than on the client, or accessingwindowon the server."
// ❌ Hydration error — window doesn't exist on server
const width = window.innerWidth; // crashes on server
// ✅ Fix — check environment or use useEffect
const [width, setWidth] = useState(null);
useEffect(() => {
setWidth(window.innerWidth); // runs client-side only
}, []);
Root cause: Rendering all rows to the DOM at once causes massive layout, paint, and scripting time.
Debugging approach:
Fix:
// Option 1 — Server-side pagination (best for data integrity)
const [page, setPage] = useState(1);
const { data } = useQuery(['contacts', page], () =>
api.get(`/contacts?page=${page}&limit=50`)
);
// Option 2 — Virtualization (for client-side lists)
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={contacts.length} itemSize={60} width="100%">
{({ index, style }) => (
<div style={style}>
<ContactRow contact={contacts[index]} />
</div>
)}
</FixedSizeList>
Interview-quality explanation:
"Virtualization only renders the visible rows in the DOM — instead of 5,000 DOM nodes, you have maybe 15. As the user scrolls, old rows are unmounted and new ones are mounted. The perceived experience is smooth and the real DOM stays lean."
Root cause: No debounce — onChange fires immediately, triggering API calls at 5-10 per second.
Fix:
// Custom debounce hook
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 400);
useEffect(() => {
if (debouncedSearch) fetchContacts(debouncedSearch);
}, [debouncedSearch]);
Root cause: A useEffect dependency changes during the effect itself, triggering the effect again.
// ❌ Infinite loop
const [data, setData] = useState([]);
useEffect(() => {
setData([...data, newItem]); // setData triggers re-render → useEffect runs again
}, [data]); // ❌ data in deps causes loop
// ✅ Fix — functional update, no need for data in deps
useEffect(() => {
setData(prev => [...prev, newItem]); // reads latest without subscribing
}, [newItem]); // ✅ only runs when newItem changes
Another common cause:
// ❌ Object created inline — new reference every render
useEffect(() => {
fetchData({ page, limit });
}, [{ page, limit }]); // new object reference = always triggers
// ✅ Fix — primitive deps
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]); // stable primitives
Root cause: JWT expired, all API calls return 401, but app doesn't handle this gracefully.
Production fix — Axios interceptor:
// axiosInstance.js
import axios from 'axios';
const api = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL });
// Request interceptor — attach token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response interceptor — handle 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
const { data } = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refresh_token')
});
localStorage.setItem('auth_token', data.accessToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return api(original); // retry original request
} catch {
// Refresh also failed — force logout
localStorage.clear();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;
Debugging steps:
undefined that the component doesn't handleRoot cause pattern:
// ❌ Crashes if user is null
<div>{user.name}</div>
// ✅ Safe access
<div>{user?.name ?? 'Unknown'}</div>
Prevention:
// Error boundary + fallback UI
<ErrorBoundary fallback={<ErrorScreen />}>
<Dashboard />
</ErrorBoundary>
Root cause: Child component not memoized. Parent re-render causes child to re-render.
Fix:
// ❌ Re-renders every time parent state changes
function Dashboard() {
const [filter, setFilter] = useState('all');
return (
<>
<FilterBar onChange={setFilter} />
<ContactTable contacts={contacts} /> {/* re-renders on every filter change */}
</>
);
}
// ✅ Memoize when props haven't changed
const ContactTable = React.memo(({ contacts }) => {
return contacts.map(c => <ContactRow key={c.id} contact={c} />);
});
// Also memoize the contacts array if it's derived
const filteredContacts = useMemo(() =>
contacts.filter(c => filter === 'all' || c.status === filter),
[contacts, filter]
);
import { useState, useEffect, useCallback } from 'react';
function ContactSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/contacts?search=${query}`);
const data = await res.json();
setResults(data);
} catch {
setError('Search failed. Please try again.');
} finally {
setLoading(false);
}
}, 400);
return () => clearTimeout(timer); // cleanup on next keystroke
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search contacts..." />
{loading && <p>Searching...</p>}
{error && <p className="error">{error}</p>}
<ul>{results.map(c => <li key={c.id}>{c.name}</li>)}</ul>
</div>
);
}
function DataTable({ columns, fetchFn, pageSize = 20 }) {
const [page, setPage] = useState(1);
const [data, setData] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetchFn({ page, pageSize })
.then(({ rows, total }) => {
setData(rows);
setTotal(total);
})
.finally(() => setLoading(false));
}, [page, pageSize, fetchFn]);
const totalPages = Math.ceil(total / pageSize);
return (
<div>
<table>
<thead>
<tr>{columns.map(col => <th key={col.key}>{col.title}</th>)}</tr>
</thead>
<tbody>
{loading
? <tr><td colSpan={columns.length}>Loading...</td></tr>
: data.map((row, i) => (
<tr key={row.id ?? i}>
{columns.map(col => <td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>)}
</tr>
))
}
</tbody>
</table>
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)}>Prev</button>
<span>Page {page} of {totalPages}</span>
<button disabled={page === totalPages} onClick={() => setPage(p => p + 1)}>Next</button>
</div>
</div>
);
}
// roles.js
export const ROLES = { ADMIN: 'admin', AGENT: 'agent', OWNER: 'owner' };
// RoleGuard.jsx
function RoleGuard({ allowedRoles, children, fallback = null }) {
const { user } = useAuth();
if (!user || !allowedRoles.includes(user.role)) {
return fallback;
}
return children;
}
// Usage — only admins see the "Delete Campaign" button
<RoleGuard allowedRoles={[ROLES.ADMIN, ROLES.OWNER]} fallback={<span>No permission</span>}>
<button onClick={deleteCampaign}>Delete Campaign</button>
</RoleGuard>
// Input: { a: 1, b: { c: 2, d: { e: 3 } } }
// Output: { a: 1, 'b.c': 2, 'b.d.e': 3 }
function flattenObject(obj, prefix = '') {
return Object.keys(obj).reduce((acc, key) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
Object.assign(acc, flattenObject(obj[key], fullKey));
} else {
acc[fullKey] = obj[key];
}
return acc;
}, {});
}
function useFetch(url, options = {}) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { ...options, signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name !== 'AbortError') {
setState({ data: null, loading: false, error: err.message });
}
});
return () => controller.abort();
}, [url]);
return state;
}
// Usage
const { data: contacts, loading, error } = useFetch('/api/contacts');
✅ Production-level answer:
"In Lease Portfolio 365, we had three roles: Admin, Manager, and Viewer. I implemented it in two layers: route-level and component-level. For routes, I created a higher-order component that checked the user's role from the auth context and redirected unauthorized users to a 403 page. For component-level, I built a
<PermissionGate>component — similar to the code I just showed — that takes a required permission prop and conditionally renders its children. Roles and permissions were fetched from the API on login and stored in context. I never stored permissions in localStorage — that's a security risk. The backend always re-validates."
Follow-up: "What if a user manually changes their role in localStorage?"
Answer: "That's exactly why we never rely on client-side data for security. Every API request goes through server-side authorization. The UI hides buttons, but the API rejects unauthorized actions regardless. Defense in depth."
✅ Full flow:
"The user logs in and receives an access token (short-lived, 15-60 min) and a refresh token (long-lived, 7-30 days). The access token is sent in every API request header. When it expires, the API returns 401. Our Axios interceptor catches that 401, pauses the original request, calls the refresh endpoint with the refresh token to get a new access token, stores it, then retries the original request with the new token. If the refresh token is also expired (user hasn't used the app in 30 days), we force logout. The refresh token should only be stored in an HTTP-only cookie — never in localStorage — because it's not accessible to JavaScript and is therefore safe from XSS attacks."
✅ Answer:
"I never override Ant Design's internal CSS classes directly — those can change between versions. Instead, I use their
themetoken system (Ant Design 5) to customize colors, spacing, and typography at the design token level. For component-level customizations, I wrap the component in my own component and expose only the props I need."
// antd v5 theme customization
import { ConfigProvider } from 'antd';
<ConfigProvider
theme={{
token: {
colorPrimary: '#003D8A', // Notifyy brand blue
borderRadius: 6,
fontFamily: 'Inter, sans-serif',
},
components: {
Table: { headerBg: '#F0F5FF' },
}
}}
>
<App />
</ConfigProvider>
✅ Answer:
"Three principles: (1) Single responsibility — each component does one thing. (2) Prop-driven — behavior is controlled by props, not internal state where possible. (3) Documented — every component has a clear prop interface, ideally with TypeScript or PropTypes. I build base components like
<Table>,<FormField>,<StatusBadge>that wrap Ant Design and apply our design tokens. Product-specific components compose these — they never reach into Ant Design directly. This means if we switch UI libraries, only the base components change."
Interviewer: "Tell me about yourself."
You: (Use the polished answer from Section 2 — HR round)
Interviewer: "You mentioned 10,000+ records optimization. What exactly was the problem and how did you diagnose it?"
You: "The initial symptom was that the table took 4-6 seconds to become interactive after data loaded. I opened Chrome DevTools Performance tab and recorded a page load. I saw a 'Long Task' of over 2 seconds in the scripting phase — React was mounting thousands of DOM nodes. I then used React DevTools Profiler to see which components were rendering and how long each took. The table component was rendering all rows even those outside the viewport. The fix was react-window for virtualization, combined with moving the data fetch to SSR so the first meaningful paint was instant."
Interviewer (pressure): "Why didn't you use pagination instead of virtualization?"
You: "We actually used both. Server-side pagination for the initial data — we fetch 500 records at a time, not all 10k. Within that 500, react-window handles virtualization so only visible rows render. Pagination alone would have required a network request on every page turn, which felt slow for financial analysts who scan data quickly. The combination gave the best of both."
Interviewer: "Show me how you'd handle a form where submitting should be disabled while a previous submission is in flight."
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (data) => {
if (submitting) return; // guard against double-submit
setSubmitting(true);
try {
await api.post('/campaigns', data);
toast.success('Campaign launched!');
router.push('/campaigns');
} catch (err) {
toast.error(err.response?.data?.message || 'Failed to create campaign');
} finally {
setSubmitting(false); // always re-enable, even on error
}
};
<button type="submit" disabled={submitting}>
{submitting ? 'Launching...' : 'Launch Campaign'}
</button>
Interviewer: "What happens if the user refreshes while submitting?"
You: "The request in flight is abandoned — the browser cancels it. The record may or may not be created on the backend depending on timing. The solution is idempotency keys — send a unique request ID with each submission. The backend uses it to detect duplicate submissions and returns the same response for repeated requests with the same key."
Interviewer: "What's your understanding of Next.js middleware?"
You: "Next.js middleware runs before a request is processed — at the edge, before the server-side functions. You can use it to do auth checks, redirects, and request rewrites without going all the way to the server. For Notifyy's use case, middleware would be ideal for checking if a user has a valid session cookie on every request to /app/* routes — and redirecting to /login if not — without the redirect happening in each page's getServerSideProps."
== and ===?this keyword in different contexts.null and undefined?let, const, and var?Array.prototype.reduce work?getStaticProps and getServerSideProps?_app.js file used for?next/dynamic and when would you use it?| # | Concept | Why Notifyy Will Ask |
|---|---|---|
| 1 | useEffect dependency rules + cleanup | Real-time inbox polling |
| 2 | useMemo for derived state | CRM contact filtering |
| 3 | useCallback for stable event handlers | Table with memoized rows |
| 4 | React.memo — when it helps and when it doesn't | Agent list components |
| 5 | Axios interceptors (request + response) | Auth token refresh |
| 6 | JWT auth flow (access + refresh tokens) | Notifyy has login + OAuth |
| 7 | React Context — proper usage and limits | Auth context |
| 8 | Controlled forms + react-hook-form | Campaign wizard |
| 9 | Debounce pattern | CRM search |
| 10 | Virtualization (react-window) | Contact/message lists |
| 11 | Error boundaries | Inbox crash recovery |
| 12 | Code splitting + React.lazy | Heavy chart components |
| 13 | SSR in Next.js | Data-sensitive pages |
| 14 | Hydration errors + fixes | Next.js app base |
| 15 | Redux Toolkit (createSlice, createAsyncThunk) | Global campaign state |
| 16 | RBAC pattern (component + route level) | Agent vs admin views |
| 17 | Optimistic UI updates | Message send feel |
| 18 | AbortController in useEffect | Cleanup on navigation |
| 19 | React 18 automatic batching | Multiple state updates |
| 20 | next/dynamic + ssr:false | Browser-only components |
Frame everything around their product. When asked about forms, say "like the campaign wizard." When asked about tables, say "like a CRM contact list." This signals you understood what they build.
Own your "basic" qualifications. You have "basic Jest" and "basic CI/CD." Don't say "I don't know much about testing." Say: "I've written component tests using Jest. I'm actively leveling up in RTL and would prioritize that at Notifyy."
Your dashboard experience is rare at 2 years. Most 2-year developers haven't touched 10k-record data optimization. Lead with this story in technical rounds — it's a strong differentiator.
The AI tools angle is a real advantage. Startup founders love this. If asked "how do you work faster," mention Copilot for scaffold generation and ChatGPT for debugging unfamiliar patterns — with the caveat that you always review and understand the output.
Prepare one question for them: "What's the most complex frontend challenge your team is currently working on?" This signals technical curiosity and helps you understand the role's depth.
End of Handbook — Gopinath C | Notifyy Interview Preparation | React.js + Next.js | May 2025