ReactHard50 min

Mini Spreadsheet

Prompt

Your challenge is to build a Mini Spreadsheet component using React.

The spreadsheet consists of a 5×5 grid of cells. Columns are labeled a through e and rows are numbered 1 through 5, giving each cell a unique identifier — for example, a1 is the top-left cell and e5 is the bottom-right.

Each cell has:

  • A text input where the user types a raw value or a formula
  • A computed value displayed below the input that updates live as the user types

If a cell's value starts with =, it is treated as a formula:

  • It may reference other cells by their identifier (e.g. a1, c3)
  • It may use basic arithmetic operators: +, -, *, /
  • Example: a cell containing =a1+b1 should display the sum of the values in cells a1 and b1

Plain numbers or text (anything not starting with =) should display as-is below the input.

Example

Mini Spreadsheet

Playground (Prompt 1)

Hint 1

Think about how to represent the spreadsheet data as a single state object. A flat object where each key is a cell identifier like 'a1' or 'b2' and the value is the raw string the user typed is a clean approach. It gives you O(1) lookup by cell ID, which you need during formula resolution.

Hint 2

To resolve a formula like =a1+b1, walk through the formula string character by character. When you encounter a column letter (ae) immediately followed by a row digit (15), you have found a cell reference. Look up the computed value for that cell and substitute it into the expression before evaluating the arithmetic.

Hint 3

Circular references (e.g. cell a1 has =b1 and cell b1 has =a1) will cause infinite recursion if not guarded against. Track which cells you have already visited during a single evaluation chain using a Set. If you encounter a cell that is already in the set, return '#CIRC' instead of recursing further.

Solution (Prompt 1)

Explanation

I will walk you through a mini spreadsheet challenge. We will go through the solution step by step. During this process, I will show you what an ideal solution looks like, where candidates go wrong and how interviewers judge your solution and decide if you are "Strong No", "No", "Yes" or "Strong Yes". Getting "Strong Yes" gives you more negotiation power.

Now before we start coding, it is very important to thoroughly read the prompt and understand the requirements. One thing I want you to notice before we touch any code is that each cell has two separate things: what the user typed into the input, and what gets displayed below it. If the user types 5, both are 5. But if the user types =a1+b1, the input still shows =a1+b1 while the display below shows the computed result, say 6. A lot of candidates get confused mid-way because they never made this distinction clear in their head before starting.

Now, let's start solving.

Thinking About State

Before we write a single line of JSX, let's think about how we want to store the spreadsheet data.

The simplest approach is a flat object where each key is a cell identifier like 'a1' or 'b2', and the value is the raw string the user typed.

// { a1: '5', b1: '1', c1: '7', a2: '=a1+b1' }
const [cellValues, setCellValues] = React.useState({});

The reason I prefer a flat object over a 2D array is that when we later need to look up the value of a1 while evaluating a formula, we can do it directly with cellValues['a1']. With a 2D array we would need to translate a to column index 0 and 1 to row index 0 every time, which adds unnecessary complexity.

We will also define two constants at the top of the file to describe the shape of our grid:

const COLUMN_LABELS = ['a', 'b', 'c', 'd', 'e'];
const ROW_NUMBERS = [1, 2, 3, 4, 5];

If the grid ever needs to grow, these are the only two lines to change.

Now let's set up App.js. I want to keep it simple — it just renders the title and our Spreadsheet component. All the spreadsheet logic will live inside Spreadsheet.

App.js

import React from 'react';
import './styles.css';
import Spreadsheet from './components/Spreadsheet';

export default function App() {
return (
<main className="app-wrapper">
<h1 className="app-title">Mini SpreadSheet</h1>
<Spreadsheet />
</main>
);
}

App is intentionally simple. It renders the title and our Spreadsheet component — nothing more.

Step 1: Rendering the Grid

Now, we will start building our Spreadsheet component. The first thing we want to do is render the 5×5 grid.

We will loop over ROW_NUMBERS and inside that loop over COLUMN_LABELS, and for each combination we construct a cell ID by joining the column letter and row number — so column 'a' and row 1 becomes 'a1'. We also set up our cellValues state and the updateCellValue function that updates a single cell.

components/Spreadsheet.js

import React from 'react';
import SpreadsheetCell from './SpreadsheetCell';

const COLUMN_LABELS = ['a', 'b', 'c', 'd', 'e'];
const ROW_NUMBERS = [1, 2, 3, 4, 5];

export default function Spreadsheet() {
const [cellValues, setCellValues] = React.useState({});

function updateCellValue(cellId, rawInput) {
setCellValues((prev) => ({
...prev,
[cellId]: rawInput,
}));
}

return (
<div className="spreadsheet">
{ROW_NUMBERS.map((rowNumber) =>
COLUMN_LABELS.map((columnLabel) => {
const cellId = columnLabel + rowNumber;
const rawValue = cellValues[cellId] || '';
return (
<SpreadsheetCell
key={cellId}
cellId={cellId}
rawValue={rawValue}
computedValue={rawValue}
onValueChange={updateCellValue}
/>
);
})
)}
</div>
);
}

Notice that for now we are passing rawValue as computedValue as well. That is intentional — we will replace it with the actual computed value in the next step once we verify the grid and state are working.

Now we need the SpreadsheetCell component that Spreadsheet is importing. I want to put it in its own file. I know some candidates avoid this because they feel it takes more time, but it keeps things clean and gives each cell a focused responsibility.

components/SpreadsheetCell.js

import React from 'react';

export default function SpreadsheetCell({
cellId,
rawValue,
computedValue,
onValueChange,
}) {
return (
<div className="spreadsheet-cell">
<input
className="cell-formula-input"
type="text"
value={rawValue}
onChange={(e) =>
onValueChange(cellId, e.target.value)
}
/>
<span className="cell-computed-value">
{computedValue}
</span>
</div>
);
}

Interview Tip

At this point, I would highly recommend adding a console.log(cellValues) inside updateCellValue and then checking a few things in the browser — does the grid look right, are the inputs working, and are there any console warnings? Only once everything looks good should you move to the next step. Catching a bug here is far easier than chasing it once the formula logic is layered on top.

Step 2: Displaying Computed Values

Now we want to show the right value below each input. For cells with plain numbers or text, that is just the value itself. For cells with formulas, we will need to evaluate them — we will add formula support in the next step.

We will create a function called getCellComputedValue. This function takes a cell ID and all current cell values, and returns what should be displayed below the input. Let's handle the simple case first:

function getCellComputedValue(cellId, allCellValues) {
const rawValue = allCellValues[cellId] || '';

if (!rawValue.startsWith('=')) {
const asNumber = Number(rawValue);
return rawValue === ''
? ''
: isNaN(asNumber)
? rawValue
: asNumber;
}

// formula support coming next
return rawValue;
}

We call Number(rawValue) to try to convert the string to a number. If the user typed '5', we get 5. If it cannot be converted (isNaN), we return the raw text as-is. And if the cell is empty we return an empty string — without this check, every empty cell in the grid would show 0 which looks wrong.

Now we will update our Spreadsheet component to use getCellComputedValue instead of passing rawValue directly as computedValue:

components/Spreadsheet.js

import React from 'react';
import SpreadsheetCell from './SpreadsheetCell';

const COLUMN_LABELS = ['a', 'b', 'c', 'd', 'e'];
const ROW_NUMBERS = [1, 2, 3, 4, 5];

function getCellComputedValue(
cellId,
allCellValues,
visitedCells = new Set()
) {
const rawValue = allCellValues[cellId] || '';

if (!rawValue.startsWith('=')) {
const asNumber = Number(rawValue);
return rawValue === ''
? ''
: isNaN(asNumber)
? rawValue
: asNumber;
}

// formula support coming next
return rawValue;
}

export default function Spreadsheet() {
const [cellValues, setCellValues] = React.useState({});

function updateCellValue(cellId, rawInput) {
setCellValues((prev) => ({
...prev,
[cellId]: rawInput,
}));
}

return (
<div className="spreadsheet">
{ROW_NUMBERS.map((rowNumber) =>
COLUMN_LABELS.map((columnLabel) => {
const cellId = columnLabel + rowNumber;
const rawValue = cellValues[cellId] || '';
const computedValue = getCellComputedValue(
cellId,
cellValues
);
return (
<SpreadsheetCell
key={cellId}
cellId={cellId}
rawValue={rawValue}
computedValue={computedValue}
onValueChange={updateCellValue}
/>
);
})
)}
</div>
);
}

Step 3: Evaluating Formulas

Now comes the most interesting part of this challenge — evaluating formulas.

When the rawValue starts with =, we need to do three things:

  1. Remove the leading =
  2. Replace every cell reference in the formula (like a1, b2) with its computed numeric value
  3. Evaluate the resulting arithmetic expression

We will handle this in a function called evaluateFormula. Now the key insight here is that our grid is a fixed 5×5, so we have exactly 25 known cell IDs from a1 to e5. Instead of parsing the formula character by character, we can simply loop over all 25 cell IDs and for each one that appears in the formula, replace it with the cell's value. We can do this cleanly using split and join.

function evaluateFormula(expression, allCellValues) {
let resolvedExpression = expression;

for (const columnLabel of COLUMN_LABELS) {
for (const rowNumber of ROW_NUMBERS) {
const cellId = columnLabel + rowNumber;
if (resolvedExpression.includes(cellId)) {
const cellValue = getCellComputedValue(
cellId,
allCellValues
);
const replacement =
typeof cellValue === 'number'
? String(cellValue)
: '0';
resolvedExpression = resolvedExpression
.split(cellId)
.join(replacement);
}
}
}

try {
return Function(
'"use strict"; return (' + resolvedExpression + ')'
)();
} catch {
return '#ERR';
}
}

split(cellId).join(replacement) is a neat trick to replace all occurrences of a string without regex. For example, 'a1+a1'.split('a1').join('5') gives us '5+5'. Once all cell references are replaced with numbers, we have a plain arithmetic expression like '5+7' that we can evaluate.

For the evaluation itself, we use the Function constructor and wrap it in a try/catch. If the expression is invalid for any reason, we catch the error and return '#ERR'.

Now let's complete getCellComputedValue with the formula branch:

function getCellComputedValue(cellId, allCellValues) {
const rawValue = allCellValues[cellId] || '';

if (!rawValue.startsWith('=')) {
const asNumber = Number(rawValue);
return rawValue === ''
? ''
: isNaN(asNumber)
? rawValue
: asNumber;
}

return evaluateFormula(rawValue.slice(1), allCellValues);
}

Production-Ready Approach

In a real production spreadsheet, neither eval() nor the Function constructor would be used — both execute arbitrary strings and are not safe. The proper approaches are:

  1. Write a proper expression parser — tokenise the input string, build an abstract syntax tree, and evaluate it without ever executing a string as code
  2. Use a battle-tested library like mathjs or expr-eval that handles parsing, operator precedence, and edge cases correctly
  3. Run evaluation in a Web Worker — isolates execution in a separate thread so even if something goes wrong it cannot affect the main thread

In an interview, using eval() or Function with an honest acknowledgement of the trade-off is completely acceptable. What matters is that you know the limitations and can articulate what you would do differently in production.

Interview Tip

I always recommend keeping computation logic completely separate from the React component. getCellComputedValue and evaluateFormula are pure functions — they take everything they need as parameters and have no side effects. It is much easier to reason about them in isolation, and much easier to explain them to the interviewer step by step. Candidates who think about separation of concerns like this consistently stand out.

Bonus: Circular References

Handling circular references is not an expectation of the interviewer for this question — it is too complex to add under time pressure on top of everything else. However, if the interviewer brings it up, you should be able to say: "Yes, there is a circular reference problem here. If a1 = '=a2' and a2 = '=a1', the two functions would keep calling each other and eventually crash the browser. I would solve it by tracking which cells have been visited during a single evaluation chain and returning an error if I encounter the same cell twice."

Here is what that looks like in code, so you understand it even if you do not implement it in the interview:

A circular reference happens when a formula ends up referencing itself — for example a1 = '=a2' and a2 = '=a1'. Without a guard, these two cells would call each other forever and crash the browser tab.

We can prevent this by passing a visitedCells set through the evaluation chain. Every time we enter a cell, we add it to the set. If we try to enter a cell that is already in the set, we know we have a loop and return '#CIRC' immediately.

function evaluateFormula(
expression,
allCellValues,
visitedCells
) {
let resolvedExpression = expression;

for (const columnLabel of COLUMN_LABELS) {
for (const rowNumber of ROW_NUMBERS) {
const cellId = columnLabel + rowNumber;
if (resolvedExpression.includes(cellId)) {
const cellValue = getCellComputedValue(
cellId,
allCellValues,
visitedCells
);
const replacement =
typeof cellValue === 'number'
? String(cellValue)
: '0';
resolvedExpression = resolvedExpression
.split(cellId)
.join(replacement);
}
}
}

try {
return Function(
'"use strict"; return (' + resolvedExpression + ')'
)();
} catch {
return '#ERR';
}
}

function getCellComputedValue(
cellId,
allCellValues,
visitedCells = new Set()
) {
const rawValue = allCellValues[cellId] || '';

if (!rawValue.startsWith('=')) {
const asNumber = Number(rawValue);
return rawValue === ''
? ''
: isNaN(asNumber)
? rawValue
: asNumber;
}

if (visitedCells.has(cellId)) return '#CIRC';

const nextVisited = new Set(visitedCells);
nextVisited.add(cellId);

return evaluateFormula(
rawValue.slice(1),
allCellValues,
nextVisited
);
}

We create a new set nextVisited instead of adding to visitedCells directly because we do not want one lookup to affect another. Think of it this way — when a2 = '=a1+b1', we look up a1 first and then b1. Both lookups should start with the same clean record of visited cells ({ a2 }). If we kept adding to the same set, the cells visited while looking up a1 would still be in the set when we go to look up b1, which could cause b1 to incorrectly return '#CIRC' even though there is no actual circular reference.

Here is a quick trace to make it concrete:

a2 = '=a1+b1'

getCellComputedValue('a2') visited = {}
→ evaluateFormula('a1+b1') visited = { a2 }
→ getCellComputedValue('a1') visited = { a2 } ✓ safe
→ getCellComputedValue('b1') visited = { a2 } ✓ safe

If a1 = '=a2':

getCellComputedValue('a1') visited = {}
→ evaluateFormula('a2') visited = { a1 }
→ getCellComputedValue('a2') visited = { a1 }
→ evaluateFormula('a1+b1') visited = { a1, a2 }
→ getCellComputedValue('a1') ✗ already visited → '#CIRC'

Step 4: Styling the Spreadsheet

Now, let's talk about styling. I use display: grid to create the grid layout. CSS Grid is the natural choice here.

*,
*::before,
*::after {
box-sizing: border-box;
}

body {
margin: 0;
}

.app-wrapper {
padding: 40px 24px;
}

.app-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 24px;
}

.spreadsheet {
display: grid;
grid-template-columns: repeat(5, 120px);
gap: 8px;
}

.spreadsheet-cell {
display: flex;
flex-direction: column;
gap: 4px;
}

.cell-formula-input {
width: 100%;
padding: 6px 8px;
border: 1.5px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
outline: none;
}

.cell-formula-input:focus {
border-color: #3b82f6;
}

.cell-computed-value {
font-size: 13px;
color: #374151;
text-align: center;
min-height: 18px;
}

grid-template-columns: repeat(5, 160px) gives us five equal-width columns. Because we are already looping rows first and columns second, CSS Grid auto-places the cells in exactly the right positions — no extra row wrapper divs needed.

One small thing I always do is add min-height: 18px to .cell-computed-value. Without it, empty cells collapse vertically and the grid looks uneven. It is a tiny detail but it makes a visible difference.

For the focused cell, we change the border colour to blue. This gives a clear visual indication of which cell is active without any extra complexity.

Communicate While You Code

Throughout the interview, it is very important to express your thoughts and the decisions you are making while writing code. A lot of candidates silently code and don't say much. Interviewers love candidates who can talk through their solutions. So don't be silent, but don't over-explain either — talk about the important decisions, for example:

  • I am using a flat object for cellValues so I can look up any cell by ID in constant time when evaluating formulas
  • I am separating SpreadsheetCell into its own component because it has a focused responsibility and keeps Spreadsheet clean
  • I am creating a new visitedCells set before each evaluation branch so independent branches don't interfere with each other

It gives the interviewer confidence that you have solid concepts and you can reason and communicate well. I have seen a lot of candidates generate solutions from AI tools but when asked how it works, they cannot explain it. In such cases the candidate is usually rejected.

Interviewer Criteria

HTML/CSS

  • Does the grid layout closely match the example? (5 columns, equal widths, consistent spacing)

  • Is the focused cell visually distinct with a clear focus ring?

  • Did you use CSS Grid to lay out the cells rather than a hardcoded table structure?

  • Did you prevent layout shift in empty cells using min-height?

  • Are CSS class names descriptive and consistent?

JavaScript

  • Did you correctly distinguish between a plain value and a formula using the = prefix?

  • Is cell reference resolution implemented without using eval() unsafely?

  • Did you validate the resolved expression before evaluating it?

  • Are getCellComputedValue and evaluateFormula pure functions with no side effects?

  • Did you handle circular references gracefully (#CIRC)?

  • Are variable and function names self-descriptive and consistent?

React

  • Is the grid rendered from data (COLUMN_LABELS, ROW_NUMBERS) rather than hardcoded JSX?

  • Did you use the key prop correctly on all iterated elements?

  • Are inputs controlled components with value and onChange?

  • Is cellValues state updated immutably using the updater function pattern?

  • Are you comfortable using React hooks and function components?

Component Architecture

  • Did you create a separate SpreadsheetCell component for a single cell?

  • Is the formula evaluation logic in pure functions outside the React component?

  • Are component and function names intuitive — can someone read the name and know what the code does?

Time Checkpoints

  • 10:00 AM

    Interview starts 👥

  • 10:03 AM

    Prompt given by the interviewer

  • 10:05 AM

    Candidate reads the prompt, asks clarifying questions, and starts coding

  • 10:12 AM

    5×5 grid rendered with controlled inputs and live state updates

  • 10:20 AM

    Plain number values display correctly below each input

  • 10:32 AM

    Formula evaluation working (=a1+b1, =c1 style references)

  • 10:40 AM

    Styling completed, matches example closely

  • 10:45 AM

    Circular reference guard added as bonus (#CIRC) 🌟

  • 10:47 AM

    Discussion with interviewer

  • 10:50 AM

    Interview ends ✅