Jira Board

The prompt

Interviewers ask this question in one or two lines and a rough sketch. This is how it was asked to me at Atlassian: "Design a Jira board. Users add tasks to the board and move them across columns as work progresses."

TODOWalkGymProgressTennisReviewCodingGamingDone

That is the entire prompt. Nothing about drag and drop, live updates, or how many people share a board. The vagueness is intentional: the interviewer wants to watch you turn a vague problem into a concrete plan and bring more clarity.

Interviewers usually pick products everyone knows, like Jira, so they expect you to already have a rough idea of what it does. If you do not, that is completely fine. Tell the interviewer you have never used a Jira board, and they will be happy to explain it. There is no penalty for asking; there is a penalty for staying quiet and designing the wrong product.

The rest of this walkthrough is how you turn that prompt into a complete design in 50 minutes. It follows the steps from the delivery framework, applied to this specific question.

Requirements and scoping

Start by asking questions to bring more clarity and remove vagueness. These are some of the questions I would have asked:

Does this need to be responsive?

No.

How much data do we need to handle? Will the amount of data coming from the API be very large?

Not very large, and not very small either. On average, a board can have around 200 items.

Do we need to make it accessible? If yes, what level of accessibility do we need to achieve?

Yes, we need to make it accessible, but the level of accessibility can be decided by you. I am thinking this should have AA level accessibility.

Could we use an external library for drag and drop, because this is something that would take a lot of time to build within a company?

Yes, you can use an external library to achieve this.

Let's say someone else updates the board from their system. Do we need to reflect that change in our system too, without refreshing the page?

Yes, if anything changes in a different window or system, it should get reflected on your screen.

If the same board is open in another tab of the same browser, should a change in one tab be reflected in the other tab?

Yes, the other tab should reflect the change too.

What should happen when two people move the same task at the same time?

A task should never be lost or duplicated.

Do I need to be given the ability to create new tasks?

If yes, you need to discuss how to create a new task using a POST API call. If no, you do not need to worry about it.

Tooling

This is not the kind of question where the interviewer focuses much on tooling. We are building a part of an application here, not a whole application, so the framework, the language, and the build setup already exist, and we build the board with whatever the codebase already uses. At a company of this size the stack is standardized and owned by a platform team, so picking my own tooling for one feature would create a second stack to maintain, not a better board. I would say exactly that, confirm the stack with the interviewer, and move on. For the rest of this walkthrough, assume the stack is React with TypeScript.

The part worth discussing is how the codebase is organized: Jira could be a standalone app inside a monorepo, sharing the design system and configs with its sibling apps. The articles on monoliths, monorepos, and choosing a repo cover that decision. In a question where you design a whole product from scratch, this step deserves real time; here, spending more than a minute on it would be a pacing mistake.

Styling

My first question here is not which CSS tool to pick; it is whether a design system exists. That splits the answer into two branches.

  • If a design system exists, which is the likely case since most companies have one, the decision is already made: colors, spacing, and base components come from it, and I follow whatever styling approach the design system uses.
  • If I am setting up styles from scratch, I would pick a zero-runtime CSS-in-JS library such as vanilla-extract or StyleX (styles written in JavaScript, compiled to plain CSS during the build), for two reasons. Styles live next to the component they belong to, so when a component is reused, its styles come with it automatically; there is no separate stylesheet to find and keep in sync. And because everything compiles to static CSS, no styling work runs in the browser, which matters on a board that re-renders constantly while a user drags.

The styling approaches article compares the options and the trade offs behind them.

API design

When the board loads, one request returns everything the screen needs: the tasks, the columns, and the order of the columns. I chose this shape because it maps beautifully onto the React components that will render it.

{
tasks: {
'task-1': { id: 'task-1', content: 'Walk' },
'task-2': { id: 'task-2', content: 'Gym' },
'task-3': { id: 'task-3', content: 'Tennis' },
'task-4': { id: 'task-4', content: 'Coding' },
'task-5': { id: 'task-5', content: 'Gaming' },
},
columns: {
'column-1': {
id: 'column-1',
title: 'Todo',
taskIds: ['task-1', 'task-2'],
},
'column-2': {
id: 'column-2',
title: 'Progress',
taskIds: ['task-3'],
},
'column-3': {
id: 'column-3',
title: 'Review',
taskIds: ['task-4', 'task-5'],
},
'column-4': {
id: 'column-4',
title: 'Done',
taskIds: [],
},
},
columnOrder: ['column-1', 'column-2', 'column-3', 'column-4'],
}

Two things to notice in this shape. Every task lives in tasks exactly once, and columns hold task ids, not tasks. And order always comes from an array: a column's taskIds array is the order of the tasks inside it, and columnOrder is the order of the columns on the board.

My interviewer said a board holds around 200 items on average. That fits comfortably in one request, so I would return the whole board and keep things simple. If a product really did have boards with thousands of tasks, we would add pagination per column, but we are fine without it here. The API design article covers how to design this contract, including pagination.

Component architecture

This response maps directly to the component tree. One Board component renders a Column for every id in columnOrder, and every Column renders a Task for each of its taskIds, looking each one up by its id.

BoardColumnColumnColumnColumnTaskTask

Those three are the repeating spine. Around them sit the fixed pieces: a dialog for task details, a control to add a task, and the loading and error states.

The code below uses Pragmatic drag and drop, the Atlassian library covered further down. You do not need to learn its API. It is here so you can see where an external library plugs into the tree you just drew: a Task registers itself as draggable, a Column registers itself as a drop target, and the Board listens for the drop and applies the move to the store. In the interview you are not writing this out, you are explaining that mapping.

import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
export default function Board({ boardId }) {
const { isLoading, error } = useBoard(boardId);
const columnOrder = useBoardStore((s) => s.columnOrder);
const moveTask = useBoardStore((s) => s.moveTask);
const [openTaskId, setOpenTaskId] = useState(null);
useEffect(() => {
return monitorForElements({
onDrop({ source, location }) {
const target = location.current.dropTargets[0];
if (!target) return;
moveTask(source.data.taskId, target.data.columnId);
},
});
}, [moveTask]);
if (isLoading) return <BoardSkeleton />;
if (error) return <BoardError />;
return (
<>
{columnOrder.map((columnId) => (
<Column
key={columnId}
columnId={columnId}
onOpenTask={setOpenTaskId}
/>
))}
<TaskDialog
taskId={openTaskId}
onClose={() => setOpenTaskId(null)}
/>
</>
);
}
function Column({ columnId, onOpenTask }) {
const column = useBoardStore((s) => s.columns[columnId]);
const ref = useRef(null);
useEffect(() => {
return dropTargetForElements({
element: ref.current,
getData: () => ({ columnId }),
});
}, [columnId]);
return (
<section>
<h2>{column.title}</h2>
<ul ref={ref}>
{column.taskIds.map((taskId) => (
<Task
key={taskId}
taskId={taskId}
onOpenTask={onOpenTask}
/>
))}
</ul>
<AddTask columnId={columnId} />
</section>
);
}
function Task({ taskId, onOpenTask }) {
const task = useBoardStore((s) => s.tasks[taskId]);
const [isDragging, setIsDragging] = useState(false);
const ref = useRef(null);
useEffect(() => {
return draggable({
element: ref.current,
getInitialData: () => ({ taskId }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
}, [taskId]);
return (
<li ref={ref} data-dragging={isDragging}>
<button onClick={() => onOpenTask(taskId)}>
{task.content}
</button>
</li>
);
}

State management

The board data comes from the server, but three things change it while the app runs: my own moves, other people's changes arriving with each refresh, and rollbacks when a save fails. So the browser keeps its own copy: the whole board lives in one store. Everything else (the task being dragged, an open dialog) stays as local state in the component that renders it. The state management tools article covers how to choose a tool for this.

Store the board normalized: a map of tasks by id, and per column an ordered array of task ids. Normalizing means a task edit updates one entry, and a move only rewrites the affected taskIds arrays rather than deep cloning nested lists.

Every change we make is an operation: move task, edit task, create task. My drag applies an operation right away. A failed save applies the inverse operation to undo itself, and the undo feature is a stack of operations to invert. One idea covers both.

For the tool, what matters is that a move re-renders only the two columns it touched, not the whole board. A small store like Zustand does this well, and plain React with a reducer works too. Because we own the store, keeping it correct (rollbacks, conflicts) is our job, and that is what the data sending step is about.

External library

For drag and drop we will use an external library, one hundred percent. Drag and drop is not a feature you build on the side. The library itself is a project, and some companies even have a team dedicated to working on their drag and drop library.

Before reaching for one, we can ask our design system or platform team whether they have the bandwidth to build and own a drag and drop library. Most likely they will not, and in that case we use an external library. The reasons hold up:

  • A serious drag and drop library handles mouse, touch, and keyboard input, auto scrolling when you drag near the edge of a column, and screen reader announcements. Building all of that yourself is months of work before you ship a single board feature.
  • These libraries carry years of bug fixes for browser and device edge cases that you would otherwise rediscover one at a time, in production.
  • Browsers keep changing, and a maintained library keeps up. A hand-rolled version becomes a permanent maintenance burden for your team.
  • Your engineering time should go into what makes your product different: the board, the collaboration, the data. Not into re-solving a problem the ecosystem has already solved.

Libraries people reach for these days:

  • dnd kit

    is the library most React teams pick first these days.
  • Pragmatic drag and drop

    is built by Atlassian and powers Jira and Trello, which makes it a strong mention in this exact interview. It also shows you researched the company and the product before walking in, which makes you look like a serious candidate who really wants to join.

Data fetching

The initial load is one request:

GET /boards/board-1

It returns everything from the API design section: the columns, their order, and all their tasks. The response fills the store.

For the fetching itself, I would use a data fetching library like SWR or TanStack Query from the start. They are lightweight, and they handle the edge cases we would otherwise write by hand: deduplicating identical requests, retrying failed ones, caching responses, and giving us loading and error states. The library does the fetching; the store stays the one home for the board data.

To keep the board fresh, I would not set up WebSockets or any push channel. Changes on a board are not high priority, so a few seconds of delay is fine and we simply refetch the same GET on an interval, around every 5 seconds. Both libraries have this built in and pause it when the tab is in the background. Polling also keeps two tabs of the same browser in sync, because each tab polls. If the product ever needed instant updates, the real-time transports article compares the push options.

One detail to handle: a refresh can arrive while our own move is still saving, carrying the board as it looked before our move. We keep our version on screen, and the data sending step covers how.

Data sending

Every change leaves the app as one POST:

POST /boards/board-1/operations

The body is the operation itself. A drag sends this:

{
type: 'move',
taskId: 'task-3',
toColumnId: 'column-4',
toIndex: 0,
}

Creating and editing use the same shape with a different type. One endpoint rather than one per change type, because the order changes happen in matters (moving the same task twice only makes sense in order), and a single stream keeps that order without any extra work.

The board never waits for the server. A drag applies the operation to the store straight away, the task appears in its new column, and the POST goes out in the background. That is the optimistic update pattern, and it is what makes a move feel instant. The optimistic updates article covers it in full.

Click the Tennis task and watch the board update before the save finishes. Then switch the server to failing and watch the same move roll back:

TodoProgressReviewWalkCodingserverTennis

Click the Tennis task to move it to the next column.

Two things can go wrong, and the operation model answers both.

  • The save fails. Apply the inverse operation: a move back to the original column, a delete for a failed create. The task returns to where it was, and we tell the user the change did not stick. This is why every operation needs an inverse.
  • Two people move the same task at once. The server applies whatever reaches it, so the later move wins. I would propose that rather than something stricter: a board is not money, and a reorder that gets overwritten costs a second to redo. What must never happen is a task going missing or appearing twice, and the operation shape prevents both. A move says where the task ends up, not how it changed, so applying it twice gives the same result as applying it once. Creates need one more thing: the client picks the task id before sending, so if the response goes missing and we retry, the server writes to the same task instead of making a second one.

That also settles the race from the fetching step, where a poll response can arrive while our own move is still in flight and carry the board without it. We hold on to every operation the server has not confirmed, so when a response lands we replace the store with the server's board and re-apply the pending ones on top. Our move stays on screen until the server confirms it, and other people's changes still come through.

Performance

A board is exactly the kind of app the performance at scale article is about: people open it in the morning and keep it open all day, so it has to stay fast for the whole session, not just load fast once. Three things keep this board fast.

  • Virtualization. A column can hold thousands of tasks, and rendering all of them creates DOM nodes nobody can see. Render only the visible window of each column. Combining this with drag and drop is tricky, because the drop target can be scrolled out of view, so check that the library you pick supports virtualized lists. The virtualization article walks through the technique.
  • Re-render containment. A user performs hundreds of drags, edits, and clicks in a session, and what matters is how fast the board responds to each one (this is what the INP metric tracks). The normalized store pays off here: moving a task changes two taskIds arrays, so only the two affected columns re-render, not the whole board. Memoize the Column and Task components so React can skip everything untouched.
  • Optimistic updates. The board never waits for the server, so every move feels instant however slow the network is. The data sending step above covers the pattern and the rollback that makes it safe.

Accessibility

Drag and drop must have a keyboard alternative. Usually you press Tab and it selects the draggable item, you move it using the arrow keys, and once you are on the droppable area you press Enter to drop it. In most cases this is handled by the library we are using; building it yourself is a lot of work, which again shows the advantage of an external library. Moves should also be announced to screen readers.

Testing

For a single page app like this board, the right shape is the testing trophy: most of the effort goes into integration tests, with unit tests reserved for pure logic and a few end-to-end journeys on top. The testing strategy article covers the shapes and how to choose between them.

  • Unit tests cover the pure logic: the function that computes where a dropped task lands, the store operations that move a task between columns, and the operation inversion that powers undo. Data in, data out, no DOM.
  • Integration tests render the Board with its real providers and act like a user: create a task, edit it, move it with the keyboard, and assert on what is on screen.
  • End-to-end tests cover the journey that would hurt the most: open the board in a real browser, drag a task to another column, reload, and check that it stayed there. Dragging needs a real browser, which is exactly what end-to-end tests are for. Keep them few, because drag interactions are where flakiness lives.

One pattern from specialized testing patterns applies directly to a board: accessibility needs interaction tests, not just automated checks. Press Tab to select a draggable task, move it with the arrow keys, press Enter to drop it on the target column, and assert that the move was announced to screen readers.