Sitemap
Dmitrii Lobanov

Dmitrii Lobanov

Senior Frontend Engineer writing about React, TypeScript, frontend performance, and scalable UI architecture.

Press enter or click to view image in full size

Frontend System Design in Practice: Building a Reliable Kanban Board in React

Optimistic updates, per-task concurrency, rollback, derived state, and the architectural decisions behind a deceptively simple interface.

36 min readJul 20, 2026

--

A Kanban board looks simple because its complexity is easy to hide behind drag-and-drop. At the UI level, it is only a set of columns, a collection of cards, and a way to move tasks between statuses.

That is enough for a prototype. It is not enough for a reliable product.

Once persistence becomes asynchronous, every movement creates several states at once: the last value confirmed by the server, the temporary value shown to the user, the request currently in progress, and the failure state that may require rollback. The board must remain responsive while different tasks are updated concurrently, prevent conflicting updates to the same task, preserve correct filtering during optimistic transitions, and apply identical business rules regardless of whether a movement starts with drag-and-drop or an explicit button.

The central problem is therefore not drag-and-drop. It is state consistency under asynchronous and concurrent changes.

This article examines the frontend system design and implementation of a reliable Kanban board built with React and TypeScript. The focus is not on reproducing a familiar interface, but on designing the state model, mutation lifecycle, and component boundaries that make the behavior predictable, testable, and extensible.

The architecture is based on a small set of deliberate decisions:

  • one logical task collection as the source of truth
  • columns and filtered results derived from current state
  • a clear boundary between confirmed and optimistic data
  • pending and error state tracked per task
  • one shared mutation pipeline for every interaction
  • immutable, localized updates
  • task-level feedback instead of global blocking states

None of these decisions is complex in isolation. Their value comes from how they work together: they reduce invalid states, isolate failures, and allow the interface to remain responsive without losing correctness.

Product and Technical Requirements

A Kanban board is easy to describe in terms of visible functionality: three columns, movable tasks, title search, assignee filtering, and two interaction methods for changing status.

Those features define what the user can do. They do not define the architecture.

The more consequential requirements describe how the board must behave while asynchronous work is in progress: updates should appear immediately, failures should roll back safely, unrelated tasks should remain interactive, duplicate mutations for the same task should be prevented, and every interaction should follow the same business rules. These behavioral constraints shape the state model, mutation lifecycle, component boundaries, and testing strategy far more than the visible interface itself.

The complete requirements are therefore divided into two groups: product capabilities and technical constraints.

Functional requirements

The board must support the following user-facing behavior:

  • present tasks across Todo, In Progress, and Done columns
  • move tasks through either drag-and-drop or explicit controls
  • reflect status changes immediately through optimistic updates
  • persist each change through an asynchronous API
  • restore the last confirmed status when persistence fails
  • display failure feedback on the affected task
  • keep unrelated tasks interactive while an update is in progress
  • allow different tasks to be updated concurrently
  • prevent conflicting updates to the same task
  • search tasks by title
  • filter tasks by assignee
  • apply search and assignee filters together

Technical requirements

The visible features describe the interface. The technical requirements define the constraints under which that interface must remain correct.

The board should preserve one logical task collection rather than maintain separate synchronized arrays for each column. Column membership, search results, and assignee-filtered views should be derived from the current task state instead of stored independently.

Confirmed server data and temporary optimistic changes should remain conceptually separate. This allows the interface to respond immediately without losing a stable rollback target when persistence fails.

Mutation state should be scoped to individual tasks. Different tasks must be able to update concurrently, while repeated updates to the same task are rejected until the current request completes.

All status changes should pass through one shared mutation pipeline. Drag-and-drop and button-based movement may capture user intent differently, but they should reuse the same validation, optimistic update, persistence, rollback, and error-handling logic.

These constraints are tightly connected. Together, they determine the state model, the mutation lifecycle, the ownership boundaries between components, and the derivation pipeline used to render the board.

The first design problem is therefore not component structure or drag-and-drop. It is defining a state model that can represent confirmed data, optimistic intent, concurrent mutations, failures, and view preferences without creating ambiguous ownership.

State Model

The requirements reveal that the board cannot be modeled as a single collection with a single lifecycle.

Each task participates in several states at once. It has data last confirmed by the server, a temporary status reflecting the user’s latest intent, a mutation that may still be unresolved, and possibly an error produced by the most recent failed request. The board also maintains view preferences such as search and assignee filters, which affect rendering without changing the task data itself.

Collapsing these concerns into one task object creates ambiguous semantics. A status field may represent either persisted reality or a provisional UI assumption, while loading and error fields introduce request-specific concerns into the domain model. Rollback then depends on reconstructing which values were confirmed and which were temporary.

A more reliable state model separates these lifecycles explicitly: confirmed task data, optimistic task state, per-task mutation state, per-task errors, and independent view preferences. This separation makes ownership clear and provides a stable foundation for concurrency, rollback, and derived rendering.

Confirmed tasks

confirmedTasks stores the latest version of each task successfully acknowledged by the backend.

It is the stable client-side representation of persisted data and should never be overwritten by a temporary optimistic assumption. When a mutation succeeds, the confirmed collection is updated with the accepted status. When it fails, the collection remains unchanged.

This makes confirmed state the rollback boundary of the application. Because optimistic changes are applied separately, the interface can always return to the last known server-approved version without reconstructing previous values or maintaining manual snapshots.

Optimistic tasks

optimisticTasks represents the board state currently presented to the user while one or more status updates are still unresolved.

When a task is moved, its requested status is applied immediately so the interface responds without waiting for the backend. This state reflects user intent, not yet persisted reality, and remains provisional until the request completes.

If persistence succeeds, confirmedTasks is updated and converges with the optimistic view. If it fails, the temporary change disappears and the board renders the unchanged confirmed state again.

Because optimistic state is modeled as a projection over confirmed data, rollback does not require storing snapshots or manually restoring previous values. It follows naturally from removing a temporary update that the server never accepted.

Pending task IDs

pendingTaskIds identifies the tasks whose status mutations are currently unresolved.

A single board-level loading flag would model concurrency incorrectly. It would disable unrelated tasks whenever any request is in progress, even though those tasks can be updated independently.

Tracking pending state by task allows the board to preserve two guarantees simultaneously:

  • a task cannot start another status change while its current request is unresolved
  • unrelated tasks remain interactive and can update concurrently

This state belongs to the mutation lifecycle rather than to the task domain model. It describes temporary client-side work in progress, so it remains separate from confirmed task data and is cleared independently when each request completes.

Task errors

taskErrors stores failure messages by task identifier, keeping mutation failures scoped to the operation that produced them.

A failed status update affects one task, not the availability of the entire board. Associating the error with that task preserves context, enables precise inline feedback, and allows unrelated work to continue without interruption.

When the user retries an update, only the previous error for that task is cleared. Errors associated with other tasks remain visible until their own lifecycle changes.

This keeps error state aligned with the same per-task ownership model used for pending mutations and prevents a local failure from becoming a global UI state.

Search query

searchQuery stores the user’s current search input and belongs to view state rather than to the task domain model.

It influences which tasks are visible by matching against their titles, but it never changes the underlying task collection. The visible result is derived from the current tasks and the active query during rendering.

Keeping search separate from domain data prevents duplicated state and synchronization logic. Updating or clearing the query therefore changes only the current projection of the board, not the tasks themselves.

Assignee filter

assigneeFilter stores the currently selected assignee and, like searchQuery, belongs to view state rather than to the task model.

It determines which tasks remain visible without modifying ownership, status, or any other domain field. The underlying task collection therefore stays unchanged while the board derives a filtered projection from the current optimistic state.

Because search and assignee filtering are independent inputs to the same derivation pipeline, they can be composed predictably without duplicating data or synchronization logic.

Why these concerns remain separate

The state model separates five concerns because each one answers a different question:

  • what the server has confirmed
  • what the user expects to see immediately
  • which tasks are currently being mutated
  • which tasks have failed
  • how the current board view should be filtered

These concerns also evolve on different timelines. Confirmed data changes only after successful persistence. Optimistic state changes as soon as the user acts. Pending state exists only while a request is unresolved. Errors persist until they are cleared or replaced. View preferences change independently of the mutation lifecycle.

Keeping these categories separate prevents temporary interaction state from leaking into the domain model and makes every transition easier to reason about, test, and recover from.

With those responsibilities defined, the next architectural question is how the task collection itself should be represented. Although the interface renders three columns, the application should not store three independent task arrays.

One Source of Truth

Although the board renders three columns, the application should preserve one logical task collection rather than maintain separate todoTasks, inProgressTasks, and doneTasks arrays.

The columns are not independent domain entities. They are projections of the same data. A task appears in a column because of its status, not because it is owned by a separate state container.

This distinction removes an entire class of synchronization problems.

Why separate column state is fragile

If each column owns its own array, moving a task becomes a coordinated multi-step operation:

  • remove the task from the source collection
  • insert it into the destination collection
  • preserve identity and ordering
  • prevent duplication or loss
  • reapply active filters consistently
  • restore both collections if persistence fails

The same logical entity is now represented in several places, and every transition must keep those representations aligned.

Rollback becomes especially fragile. A failed request is no longer resolved by restoring one property. The application must reconcile multiple collections while accounting for concurrent changes that may have occurred in the meantime.

Status defines column membership

A simpler model stores every task once and uses status to determine where it appears.

type Task = {
id: string;
title: string;
assignee: string;
status: "todo" | "in-progress" | "done";
};

Moving a task then becomes an immutable update to one field:

tasks.map(task =>
task.id === taskId
? { ...task, status: nextStatus }
: task
);

The board does not transfer task objects between state containers. It updates the task and derives the new visual grouping from the resulting collection.

This model also aligns naturally with the backend: the task remains one resource whose status changes over time.

Filtering and grouping remain derivations

A single task collection allows search, assignee filtering, and column grouping to form one explicit pipeline:

confirmed tasks
→ optimistic task view
→ search and assignee filters
→ grouping by status
→ column rendering

Each stage transforms the result of the previous one without introducing another stored representation.

This keeps filtering logic centralized, ensures that every column is based on the same visible dataset, and allows optimistic status changes to propagate automatically through the board.

Immutable updates remain localized

Because a status change replaces only the affected task object, unrelated tasks preserve their references.

This keeps the update small, supports predictable rendering, and makes the transformation easy to test. Given an initial collection, a task ID, and a target status, the expected guarantees are explicit:

  • exactly one task changes
  • every unrelated task remains unchanged
  • the collection size is preserved
  • no task is duplicated or removed

These guarantees are significantly harder to maintain when movement is implemented as coordinated deletion and insertion across several arrays.

One source of truth does not mean one state value

The board still maintains separate state for confirmed tasks, optimistic state, pending task IDs, errors, and filters. Those values represent different lifecycles and responsibilities.

The one-source-of-truth principle applies specifically to the domain collection: there is one logical set of tasks, not one independently stored collection per column.

Confirmed and optimistic views may temporarily differ, but both represent the same domain entities at different stages of the mutation lifecycle.

Architectural consequence

With one task collection, a status change becomes an atomic domain update. Column membership and filtering remain derived, rollback returns to confirmed data, and concurrency stays isolated to the affected task.

The architecture therefore represents fewer invalid states and requires less synchronization logic.

The next step is to define how the board derives filtered and grouped views from this collection without turning those views into additional state.

Press enter or click to view image in full size

Derived Views

Once the board maintains one logical task collection, its columns no longer need independent state.

They become projections of the current task data.

This distinction is fundamental. State represents information the application owns and changes directly. A derived view represents information that can always be recalculated from existing state. Because a task’s status fully determines its column, storing both the task collection and separate column arrays would duplicate the same fact.

Filtering precedes grouping

The board supports two independent view constraints:

  • title search
  • assignee selection

Neither changes the underlying task data. They only determine which tasks remain visible.

The first derivation stage is therefore filtering:

optimistic tasks
│
▼
search + assignee filters
│
▼
visible tasks

The result is still a complete task collection, only narrowed to the items relevant to the current view.

Columns are derived from visible tasks

Once the visible collection has been computed, the board groups those tasks by status:

visible tasks
│
▼
group by status
│
▼
Todo | In Progress | Done

The application never stores todoTasks, inProgressTasks, or doneTasks as state. Those groupings exist only as render-time projections.

This has three important consequences.

First, filtering logic remains centralized. Adding another filter, such as priority or due date, extends one derivation pipeline instead of duplicating logic across columns.

Second, every column is produced from the same visible dataset, which removes the risk of inconsistent filtering behavior.

Third, optimistic updates propagate automatically. When a task’s optimistic status changes, it leaves one derived group and appears in another without any explicit synchronization.

Derivation pipeline

The complete rendering flow is a sequence of pure transformations:

confirmed tasks
│
▼
optimistic projection
│
▼
optimistic tasks
│
▼
search filter
│
▼
assignee filter
│
▼
visible tasks
│
▼
group by status
│
▼
render columns

Each stage has one responsibility, and no stage mutates its input. This makes the data flow explicit and reduces the number of states the application must reason about during debugging.

When useMemo helps

Filtering and grouping run during rendering. For a board with a modest number of tasks, these operations are inexpensive and usually do not justify additional memoization.

As the dataset grows, however, recomputing the same projections after unrelated state changes can become wasteful. In that case, useMemo can cache derived values according to their real dependencies:

visibleTasks
depends on
(optimisticTasks, searchQuery, assigneeFilter)

columns
depends on
(visibleTasks)

This keeps recomputation aligned with the values that actually influence the result.

Memoization is not the architecture

useMemo does not make this design correct.

The architectural decision is to derive filtered views and column groupings rather than store them independently. Memoization is only an optimization that may reduce the cost of producing those views.

The model should remain correct without memoization. Performance tuning comes later, guided by dataset size, render frequency, and profiling.

With the rendering pipeline defined, the next problem is mutation handling: the board must update immediately after user intent while preserving a reliable path back to confirmed state when persistence fails.

Optimistic Updates

The central mutation problem is separating immediate user intent from confirmed server state.

When a task moves, the interface should respond at once. Waiting for persistence would make the board feel slow, even when the network delay is relatively small. At the same time, the backend may reject the change, so the application must represent both what the user currently sees and what the server has actually accepted.

These are related states, but they are not the same state.

Press enter or click to view image in full size

Confirmed and optimistic state serve different purposes

The confirmed task collection represents the latest data successfully acknowledged by the backend.

The optimistic view represents the temporary state the user expects to see while a mutation is unresolved.

                 User moves task
│
▼
Apply optimistic status
│
▼
Persist asynchronously
│
┌──────────────┴──────────────┐
▼ ▼
Success Failure
│ │
▼ ▼
Commit confirmed state Remove optimistic change
│ │
▼ ▼
UI remains unchanged UI returns to confirmed state

The optimistic projection exists only while persistence is uncertain. After the request completes, it either converges with confirmed data or disappears.

Mutation lifecycle

Every status change follows one explicit sequence:

             User action
│
▼
Validate request
│
▼
Mark task as pending
│
▼
Clear previous task error
│
▼
Apply optimistic status
│
▼
Persist to backend
│
▼
┌───────────────┴───────────────┐
▼ ▼
Success Failure
▼ ▼
Update confirmed state Preserve confirmed state
▼ ▼
Clear pending Remove optimistic change
│
▼
Attach task-level error
│
▼
Clear pending

The interface responds before the request begins, but the confirmed collection changes only after success. On failure, the stable server-approved state remains intact, the optimistic projection is removed, and feedback is attached to the affected task.

The same lifecycle applies regardless of whether the mutation originates from drag-and-drop or an explicit control.

Why useOptimistic fits the model

React’s useOptimistic hook expresses this boundary directly. The application keeps confirmed state as the durable base while React applies temporary transformations that represent unresolved user intent.

  confirmed tasks
│
▼
optimistic transformation
│
▼
current optimistic view

If persistence succeeds, confirmed state is updated to match the accepted result. If it fails, the optimistic transformation is no longer retained, so rendering falls back to the unchanged confirmed collection.

Rollback is therefore not a separate reconstruction algorithm. It is a consequence of preserving confirmed data and treating optimistic changes as temporary overlays.

Responsiveness must remain honest

Optimistic updates improve perceived performance, but they should not conceal failure.

When a rejected mutation causes a task to return to its previous column, the interface should explain why. Without contextual feedback, rollback looks like an unexplained reversal of the user’s action.

A complete optimistic workflow therefore requires more than an immediate visual update. It also requires pending state, a stable rollback boundary, and task-level error reporting.

Architectural consequence

Separating these concerns removes the need to store previous values manually or reconstruct failed transitions.

The model remains simple:

  • confirmed state represents accepted backend data
  • optimistic state represents unresolved user intent
  • pending state represents active work
  • error state represents a failed mutation

Each concern has a distinct lifecycle, which makes asynchronous transitions easier to reason about and test.

The next challenge is concurrency. Multiple tasks should be able to move optimistically at the same time, while conflicting mutations against the same task must remain blocked.

Per-Task Concurrency

Optimistic updates improve responsiveness, but they do not solve concurrency.

A production board should allow multiple independent mutations to progress at the same time. Moving one task should not prevent users from continuing work on unrelated tasks.

Press enter or click to view image in full size

Why a global loading flag breaks concurrency

A common implementation models mutation state with a single boolean:

isPending = true;

This implies that the entire board is busy whenever any request is in flight.

As a consequence:

  • every movement control becomes disabled
  • drag-and-drop is blocked
  • unrelated tasks become temporarily read-only

The interface behaves as though one task owns the entire application, even though the underlying operations are independent.

Pending state belongs to each task

Mutation state is not a property of the board. It is a property of an individual task.

Instead of asking whether the application is updating, the relevant question is whether this task is updating.

The board therefore tracks the identifiers of tasks with unresolved mutations:

Pending tasks
{ task-2, task-7, task-15 }

Before starting a mutation, the application checks whether the target task is already pending.

If it is, the request is rejected.

If it is not, the mutation begins immediately.

This allows unrelated tasks to progress independently while protecting each task from conflicting updates.

Independent mutation lifecycles

With per-task tracking, concurrent updates become the default behavior rather than a special case.

Task A   ───────────────► pending
Task B ───────────────► pending
Task C ───────────────► pending

Each mutation has its own lifecycle, succeeds or fails independently, and clears its own pending state when finished.

The board therefore remains responsive even while several network requests are executing simultaneously.

Preventing conflicting updates

Concurrency should exist between different tasks, not within the same task.

Consider the following sequence:

    Move Task A
│
▼
Request started
│
▼
User moves Task A again

Without protection, two requests would be sent for the same resource. Since network responses are not guaranteed to arrive in order, an older response could overwrite a newer state.

Blocking additional mutations while a task is pending eliminates this class of race conditions before they reach the backend.

Immutable pending state

The pending collection follows the same immutable update model as every other piece of React state.

{}
↓
{ Task A }
↓
{ Task A, Task B }
↓
{ Task B }
↓
{}

Each transition creates a new representation of the active mutations, making state updates predictable and easy for React to detect.

Architectural consequence

Tracking pending state per task allows the board to combine responsiveness with correctness.

Independent tasks remain interactive, conflicting updates are prevented locally, and every mutation progresses through its own isolated lifecycle.

With concurrency handled at the task level, the remaining challenge is ensuring that every interaction — whether initiated through drag-and-drop or a button — executes the same mutation pipeline.

Drag-and-Drop Design

Drag-and-drop is the most visible interaction in a Kanban board, but it should not define the application’s business logic.

From the architecture’s perspective, dragging a card and clicking a movement control express the same intent: change the status of one task.

Treating drag-and-drop as an input mechanism rather than as a separate workflow keeps interaction code thin and prevents duplicate implementations from diverging over time.

One domain operation

The board exposes one status transition operation:

changeTaskStatus(taskId, nextStatus);

Every interaction delegates to it.

Whether the user drops a card into another column or selects an explicit movement action, the same pipeline runs:

  • validate the transition
  • reject conflicting updates
  • clear the previous task error
  • mark the task as pending
  • apply the optimistic status
  • persist the mutation
  • commit or roll back
  • clear the pending state

The interaction determines how intent is captured. It does not determine how the mutation is executed.

Native drag-and-drop flow

With the browser’s native drag-and-drop API, the source card places only its task identifier in dataTransfer when dragging begins:

Drag start
│
▼
Store task ID

The destination column already owns the status it represents. On drop, the application translates the browser event into a domain request:

Drag start
│
▼
dataTransfer ← taskId
│
▼
Drop on destination column
│
▼
Read latest task state
│
▼
changeTaskStatus(taskId, destinationStatus)

The drop handler therefore performs minimal work. It extracts the identifier, determines the destination status, retrieves the current task, and delegates to the shared mutation pipeline.

Validate against current state

The task data captured when dragging begins may already be stale by the time the card is dropped.

During that interval, another mutation may have completed, the task may have moved, a request may have become pending, or a real-time update may have changed its status.

For that reason, the drop handler should carry only the stable task identifier and resolve the latest task state immediately before validation.

This avoids decisions based on stale snapshots and ensures that every transition is evaluated against the current application state.

Reject invalid transitions early

Before invoking the mutation pipeline, the board rejects operations that cannot produce a valid state change:

  • the task no longer exists
  • the destination matches the current status
  • the task already has an unresolved mutation
Task exists?
│
Yes
│
Status changes?
│
Yes
│
Task available?
│
Yes
│
▼
Execute mutation

Early validation prevents redundant API calls and keeps the asynchronous pipeline focused on meaningful transitions.

Shared logic prevents behavioral drift

Separate implementations for buttons and drag-and-drop may look harmless initially, but they create long-term inconsistency.

A permission rule may be added to one path but not the other. Analytics may be emitted only for clicks. Retry behavior, accessibility checks, or rollback changes may evolve independently.

Routing every interaction through the same status-change operation ensures that new requirements apply consistently across the board. Presentation mechanisms remain replaceable while validation, persistence, concurrency, and recovery stay centralized.

Native API versus a dedicated abstraction

The native browser API is sufficient for a small board with straightforward desktop interactions. It adds no runtime dependency and keeps the implementation focused on state transitions rather than drag mechanics.

Its limitations become more important when the product requires touch support, keyboard dragging, custom previews, nested drop targets, advanced collision handling, or sophisticated animation.

Libraries such as dnd-kit or Pragmatic Drag and Drop can provide those capabilities without changing the underlying architecture.

The interaction layer should detect and translate user intent. The application should continue to own the business operation.

Error Handling and Rollback

Optimistic updates make the interface responsive, but they also introduce the possibility of temporary divergence between the client and the server.

When persistence fails, the application must recover without leaving the board in an inconsistent state or forcing users to guess what happened.

Recovery therefore has two responsibilities:

  • restore a consistent state
  • explain the failure in context

Rollback restores consistency

Because confirmed and optimistic state are modeled separately, rollback is a natural consequence of the state model rather than a special recovery procedure.

Get Dmitrii Lobanov’s stories in your inbox

Join Medium for free to get updates from this writer.

If the backend rejects a mutation, the confirmed collection remains unchanged. The optimistic projection is removed, and rendering automatically returns to the last server-approved state.

Confirmed state
│
▼
Optimistic projection
│
▼
Persistence fails
│
▼
Render confirmed state

No previous values need to be reconstructed manually. The application simply stops presenting data that was never accepted by the backend.

Errors belong to the affected task

Restoring state answers what the board should display. It does not explain why the transition failed.

A global notification is often insufficient when multiple mutations may complete simultaneously. If one task succeeds while another fails, a generic error message provides little context.

Instead, failure should remain attached to the task that produced it.

Todo
──────────────────────────────
Implement retry support

⚠ Failed to update status.
Please try again.
──────────────────────────────

This immediately answers three questions:

  • which task failed
  • why the status changed back
  • what the user can do next

Meanwhile, the rest of the board remains fully interactive.

Failures remain local

A failed mutation should not affect unrelated work.

One rejected request must not disable every movement control, interrupt drag-and-drop, or prevent other pending mutations from completing.

Associating error state with individual tasks keeps failures isolated to the entity that produced them, mirroring the same ownership model already used for pending mutations and optimistic updates.

Error lifecycle

Errors should persist only while they remain relevant.

When the user retries the operation, the previous error is cleared before the next mutation begins.

Request fails
│
▼
Attach task error
│
▼
User retries
│
▼
Clear previous error
│
▼
Start new mutation

This ensures the interface always reflects the current mutation rather than stale feedback from a previous attempt.

Architectural consequence

Rollback is only one stage of a complete mutation lifecycle.

A reliable asynchronous workflow combines:

  • optimistic updates
  • per-task pending state
  • backend persistence
  • automatic rollback
  • task-level error reporting

Each concern has a single responsibility. Together, they allow the board to remain responsive while preserving consistency and making failures easy to understand.

Component Boundaries

The state model defines the ownership model.

Confirmed tasks, optimistic updates, mutation state, errors, filters, and derived views all participate in the same board-level workflow. Splitting these concerns across unrelated components would create duplicated coordination logic and unclear responsibility.

For this implementation, KanbanBoard owns the application behavior, while its children remain focused on rendering and translating user interaction into domain intent.

KanbanBoard as the orchestration boundary

The root board component owns the state and operations that affect the board as a whole:

  • confirmed and optimistic task data
  • pending task identifiers and task-level errors
  • search and assignee filters
  • visible-task and column derivations
  • validation and optimistic mutation handling
  • persistence through the API layer
                 KanbanBoard
┌───────────────┼────────────────┐
│ │ │
▼ ▼ ▼
View state Derived views Mutation pipeline
│
▼
API layer

This component acts as the orchestration boundary rather than merely as a visual container. It coordinates transitions between state categories and exposes narrow inputs to presentation components.

Centralizing orchestration here ensures there is one implementation of validation, concurrency control, optimistic updates, persistence, rollback, and error handling.

KanbanBoardColumn as an interaction boundary

A column receives only the information required to render one status group and report user intent:

  • column title and status
  • tasks currently projected into that status
  • task-level pending and error information
  • callbacks for status changes and drop events

The column does not decide whether a transition is valid and does not communicate with the backend.

Its role is to translate presentation events into a domain request:

KanbanBoardColumn
│
▼
move task-42 to done

The board then evaluates that request against the latest state and executes the shared mutation pipeline.

TaskCard as the smallest presentation unit

Each card renders one task and the interaction state associated with it:

  • task content
  • available movement controls
  • drag-source behavior
  • pending feedback
  • inline mutation errors

The card does not own persistence, rollback, or mutation rules. It emits intent through callbacks supplied by the board.

This keeps task rendering reusable and prevents infrastructure concerns from leaking into the smallest UI unit.

Filters own input, not derivation

Search and assignee controls own only their current input values and change events.

They do not filter tasks directly. Instead, they report the updated query or selected assignee to the board, which recalculates the visible task projection from the latest optimistic collection.

This keeps derivation logic centralized and prevents different controls or columns from producing incompatible views of the same data.

One mutation pipeline

The most important consequence of these boundaries is that every status transition converges on one operation:

Button interaction ───────┐
│
Drag-and-drop ────────────┤
▼
Shared status operation
│
▼
Validation
│
▼
Optimistic mutation
│
▼
Persistence
│
▼
Commit or rollback

Interaction components produce the same domain intent: move one task to one status. They do not own alternative implementations of the same business operation.

This boundary becomes increasingly valuable as the system evolves. Permissions, analytics, retries, conflict detection, and real-time synchronization can be introduced once inside the mutation pipeline rather than repeated across every interaction path.

Presentation and behavior can evolve independently

Because business behavior is owned above the presentation layer, the interface remains replaceable.

Columns can be redesigned, cards can expose more metadata, movement buttons can become menus, and the native drag-and-drop implementation can be replaced with a dedicated library without changing the mutation lifecycle.

The reverse is also true: persistence, validation, or synchronization can evolve without rewriting presentation components.

The architectural goal is not to place all code in one large component. It is to centralize ownership while allowing implementation details to be extracted into focused hooks, services, and pure functions without changing where responsibility belongs.

Clear ownership boundaries reduce behavioral drift and make the system easier to test, extend, and reason about.

Performance Considerations

Performance should emerge from the architecture before it is reinforced with React-specific optimizations.

Many rendering problems begin with duplicated state, broad ownership boundaries, and updates that invalidate more of the interface than the user action requires. Hooks such as useMemo and React.memo can reduce symptoms, but they cannot compensate for a state model that performs unnecessary work by design.

The board remains efficient primarily because it stores little, derives deliberately, and localizes every mutation.

Derived state avoids synchronization work

Columns are calculated from the current task collection rather than maintained as independent state.

A status change therefore updates one task object in one logical collection. The board then recalculates the visible projection instead of coordinating removal, insertion, filtering, and rollback across several stored arrays.

This reduces both computational work and synchronization complexity. The cost of a transition remains tied to the underlying domain change rather than to the number of duplicated representations that must be kept consistent.

Immutable updates preserve structural sharing

A status mutation replaces only the affected task object. Unrelated tasks retain their existing references.

Before
Task A ───────── same reference
Task B ───────── old object
Task C ───────── same reference

After updating Task B
Task A ───────── same reference
Task B ───────── new object
Task C ───────── same reference

This structural sharing makes updates predictable and creates the conditions under which memoized task components can skip unrelated renders.

Immutability alone does not guarantee rendering efficiency, but it gives React and component-level memoization stable reference boundaries to work with.

Stable identity supports reconciliation

Every task uses a stable, unique identifier as its React key.

This allows React to preserve the identity of a task as it moves between derived columns. Local component state can remain associated with the same task, and React can reconcile the change without treating it as an unrelated replacement caused by positional keys or unstable identifiers.

Stable keys do not eliminate all rendering work, but they prevent avoidable remounting and incorrect identity reuse.

Mutation scope should match interaction scope

Moving one task should invalidate as little of the interface as possible.

The state update changes one entity, pending and error state are scoped to that entity, and the resulting columns are recalculated from the updated collection. No unrelated task data is rewritten merely because one status changed.

This is the important performance boundary: the scope of the state transition should remain close to the scope of the user action.

When memoization becomes useful

Filtering and grouping are pure derivations whose cost grows with the number of tasks and the frequency of renders.

For a modest board, recalculating them is usually cheaper than introducing and maintaining memoization. As the collection grows, caching may become valuable when profiling shows repeated derivation work caused by unrelated state changes.

The dependency boundaries are explicit:

visibleTasks
depends on
(optimisticTasks, searchQuery, assigneeFilter)

columns
depends on
(visibleTasks)

useMemo can preserve these results while their inputs remain unchanged. React.memo can provide value at component boundaries where props remain referentially stable and rendering cost is measurable.

The optimization should follow the dependency graph rather than be applied indiscriminately.

Memoization is a measured optimization

Memoization has its own costs: cached values consume memory, dependencies must be compared, and additional abstractions make the render path harder to understand.

For inexpensive computations, those costs may equal or exceed the work being avoided.

The preferred sequence is therefore:

reduce duplicated state
↓
localize updates
↓
preserve stable references
↓
profile real interactions
↓
memoize proven bottlenecks

This keeps performance work evidence-based and avoids turning speculative optimization into permanent complexity.

Scaling beyond moderate datasets

As task counts move into the hundreds or thousands, rendering cost usually becomes more important than the cost of a single immutable update.

Several optimizations can then be introduced at the appropriate boundary:

  • virtualize long columns so only visible cards are mounted
  • debounce expensive search input when immediate recomputation is unnecessary
  • index frequently queried properties when repeated full scans become costly
  • move CPU-intensive derivations to a worker if they begin blocking the main thread
  • move filtering, sorting, and pagination to the backend when the client should no longer hold the complete dataset

Normalized storage may also become useful when the application performs frequent entity lookups or maintains independent ordering metadata for each column.

These changes extend the architecture rather than replace it. Domain state, optimistic intent, mutation state, and rendered projections remain separate even when their internal representations become more sophisticated.

Architectural consequence

The strongest performance decision in this board is not a hook. It is the reduction of unnecessary state and the localization of change.

Because the architecture preserves one logical task collection, derives views explicitly, and scopes asynchronous state per task, optimization can be introduced incrementally at clear boundaries without redesigning the mutation model.

Accessibility Considerations

Accessibility is not a presentation-layer enhancement added after the interaction model is complete.

It is influenced by the same architectural decisions that govern state ownership, mutation flow, feedback, and component boundaries. An interface cannot be considered correct if its core operations are unavailable to keyboard users, assistive technologies, or touch users.

Drag-and-drop must have an equivalent interaction

Drag-and-drop is efficient for pointer users, but it cannot be the only way to move a task.

Every status transition must also be available through explicit, focusable controls. These controls should expose the same domain operation as drag-and-drop rather than introduce a second implementation.

Pointer drag ─────────────┐
│
Keyboard-accessible action┤
▼
changeTaskStatus(...)

Because both paths converge on the shared mutation pipeline, validation, permissions, optimistic updates, rollback, and error handling remain consistent across interaction methods.

Accessibility is therefore preserved without duplicating business logic.

Interaction state must be exposed programmatically

Visual feedback alone is insufficient for asynchronous state changes.

When a task is pending, the interface should expose that state through semantics that assistive technologies can interpret. Applying aria-busy="true" to the affected card communicates that the task is temporarily processing an update.

The associated controls should also reflect whether they are currently available. Using native disabled states where appropriate prevents repeated activation and communicates the same constraint to keyboard and screen-reader users that pointer users perceive visually.

Because pending state is tracked per task, this feedback remains localized. One busy card does not imply that the entire board is unavailable.

Failures must be announced in context

A rollback is a significant state transition. The card moves back to its confirmed column, and the user needs to understand that the operation failed rather than assuming the interface behaved unpredictably.

Task-level error messages should therefore be exposed through an appropriate live region, such as role="alert" for urgent feedback or aria-live when announcement timing requires more control.

Task mutation fails
│
├── rollback visual state
│
└── announce contextual error

Because the error remains associated with the affected task, the message can identify which action failed without relying on a detached global notification.

Focus should remain predictable after the failure. A rollback should not unexpectedly move keyboard focus to another component or force the user to rediscover the task.

Semantic structure supports navigation

The board should expose its information hierarchy through semantic HTML rather than through visual layout alone.

Each column represents a labeled region of related tasks and should be associated with a clear heading. Tasks should be represented as distinct items within that region, and interactive controls should use native elements with descriptive accessible names.

This allows users to navigate by headings, regions, controls, or list items instead of traversing an undifferentiated collection of generic containers.

Search and assignee controls also require explicit labels, and updates to the filtered result set should not produce unexpected focus changes.

The objective is to communicate the board’s structure and available operations independently of its visual styling.

Accessibility follows the ownership model

The accessibility behavior maps directly to state that already exists:

  • per-task pending state drives localized busy and disabled semantics
  • task-level errors drive contextual live announcements
  • shared mutation logic keeps all interaction methods behaviorally equivalent
  • derived views keep filtering consistent for every input method
  • stable component ownership helps preserve predictable focus and feedback

This is another benefit of separating concerns correctly. Accessibility semantics can be attached at the component boundary that owns the relevant state rather than reconstructed from unrelated global conditions.

Accessibility is part of correctness

For an interactive system, correctness includes more than preserving data under asynchronous failure.

Users must be able to discover, operate, and understand the same workflow regardless of whether they use a pointer, keyboard, touch input, or assistive technology.

Treating accessibility as an architectural constraint produces a board whose interaction model, feedback, and recovery behavior remain consistent across users rather than merely visually complete.

Trade-offs

Every architectural decision reduces one form of complexity by accepting another.

There is no universally correct Kanban architecture. The appropriate design depends on interaction requirements, expected data volume, collaboration needs, browser support, and the operational model of the backend.

The implementation in this article deliberately favors the simplest design that preserves correctness under optimistic updates, concurrent mutations, and failed persistence. Its value lies not in maximizing abstraction, but in making the current trade-offs explicit and preserving clear paths for future evolution.

useOptimistic versus manual optimistic state

Both approaches separate confirmed data from temporary user intent, but they differ in how rollback is modeled.

A manual implementation usually requires the application to capture previous values, apply temporary changes explicitly, and restore snapshots when persistence fails. This can be effective for small mutation surfaces, but rollback logic often becomes increasingly coupled to individual operations as the number of mutation types grows.

useOptimistic instead treats optimistic state as a temporary projection over confirmed data. The confirmed collection remains the stable base, while optimistic transformations represent unresolved intent.

confirmed state
│
▼
optimistic transformation
│
▼
temporary view

If persistence fails, the application does not reconstruct a previous state. It stops applying the optimistic transformation and renders the unchanged confirmed data again.

This produces a cleaner model when React’s optimistic workflow matches the mutation requirements.

The trade-off is tighter coupling to modern React behavior, a less familiar mental model for some teams, and reduced flexibility when optimistic operations require custom queues, long-lived offline state, or complex conflict resolution.

Native drag-and-drop versus a dedicated library

The native browser API has a small implementation footprint and introduces no additional runtime dependency.

For a compact desktop-oriented board, it is sufficient to capture drag intent and delegate the resulting status change to the application’s mutation pipeline.

The cost appears when interaction requirements expand. Touch support, keyboard dragging, custom previews, collision strategies, nested targets, auto-scrolling, and advanced animations require substantial additional work when built directly on native primitives.

Libraries such as dnd-kit or Pragmatic Drag and Drop provide stronger interaction abstractions and broader input support, but they also increase dependency surface, configuration complexity, and the amount of library-specific behavior the team must understand.

For the requirements in this article, the native API keeps attention on state consistency rather than drag mechanics. A more interaction-heavy product would justify a dedicated library earlier.

Independent state values versus a reducer

The board uses separate state values because confirmed tasks, pending mutations, errors, and filters have distinct ownership and lifecycles.

This keeps each update direct and makes the state model easy to inspect.

A reducer becomes more valuable when transitions must be coordinated atomically or when the number of valid state transitions becomes difficult to reason about through independent setters.

Features such as undo and redo, batch mutations, offline synchronization, collaborative editing, or explicit workflow state machines often benefit from reducer-based architectures because every transition is represented as an action with one centralized update path.

The trade-off is additional ceremony. Actions, reducer cases, and transition conventions improve control, but they also increase abstraction and can obscure simple updates when the state machine is still small.

For the current requirements, independent state values remain proportionate. A reducer would become justified when coordination complexity begins to exceed the clarity of direct updates.

Client-side filtering versus server-side filtering

Client-side filtering provides immediate feedback and avoids a network request for every search or assignee change.

It works well while the complete task collection is small enough to fetch, retain, and scan efficiently in the browser.

The limitation is data scale. Once the board represents a much larger dataset, downloading all tasks becomes inefficient, client memory grows, and repeated scans become increasingly expensive.

At that point, filtering, sorting, and pagination should move to the backend so the client requests only the relevant result window.

The architectural shift remains manageable because the UI already treats filtered tasks as a projection. What changes is where the projection is computed:

small dataset:   client data → client derivation → rendered view
large dataset: query intent → server derivation → rendered view

The trade-off is latency and synchronization complexity. Server-side filtering reduces client workload but introduces request cancellation, loading states, cache invalidation, pagination, and stale-result handling.

Arrays versus normalized state

A single task array keeps the model readable and makes filtering, grouping, and immutable status updates straightforward.

For moderate collections, an O(n) scan is usually inexpensive and easier to maintain than a more complex normalized structure.

Normalized state becomes useful when the application performs frequent identifier-based lookups, updates many related views, or stores ordering independently from entity data.

tasksById
│
├── task-1
├── task-2
└── task-3

columnOrder
│
├── todo: [task-1]
├── in-progress: [task-2]
└── done: [task-3]

This representation can make entity updates more localized and supports explicit per-column ordering without duplicating task objects.

The cost is greater coordination complexity. Entity dictionaries and ordering collections must remain consistent, selectors become more important, and simple operations require more indirection.

For this board, a single array provides the better balance. Normalization becomes justified when lookup frequency, ordering requirements, or cross-feature reuse make the simpler representation a measurable constraint.

Simplicity versus scalability

The recurring decision across all of these trade-offs is proportionality.

Every abstraction introduces concepts that the team must understand, maintain, and test. Choosing the most sophisticated design too early often shifts complexity from future requirements into the present implementation without delivering immediate value.

The architecture in this article therefore favors simple representations, explicit ownership, and centralized mutation behavior while preserving upgrade paths for richer interaction libraries, reducers, normalized state, server-side queries, and collaborative synchronization.

Scalability does not require solving every future problem in advance. It requires making today’s design easy to extend when new constraints become real.

The strongest architecture is therefore not the most advanced one. It is the one whose trade-offs match the current requirements and whose boundaries make future change predictable.

Implementation

The architecture presented throughout this article has been implemented as a production-minded React and TypeScript application.

Rather than reproducing the entire codebase, this section shows how the architectural decisions translate into code. The complete implementation, tests, and project structure are available in the accompanying GitHub repository, while the live demo allows you to explore the behavior interactively.

GitHub: https://github.com/Dmitrii-Lobanov/kanban-board

Live Demo: https://kanban-board-psi-taupe.vercel.app/

Project structure

The implementation mirrors the ownership model introduced during the system design discussion.

src/
├── api/
├── components/
│ ├── KanbanBoard
│ ├── KanbanBoardColumn
│ ├── TaskCard
│ └── TaskFilters
├── hooks/
├── types/
├── utils/
└── App.tsx

The exact folder structure is less important than responsibility. KanbanBoard owns the application state and mutation pipeline, presentation components focus on rendering, and the API layer isolates persistence.

Architectural decisions in code

The implementation directly reflects the architecture described throughout this article:

  • State model separates confirmed tasks, optimistic state, pending mutations, task-level errors, and view preferences.
  • Shared mutation pipeline routes every status change through one workflow, regardless of whether it originates from a button or drag-and-drop interaction.
  • Derived views calculate filtering and column grouping from a single task collection instead of storing multiple synchronized arrays.
  • Component boundaries keep business logic inside KanbanBoard, while columns and task cards remain presentation-focused.
  • Native drag-and-drop translates browser events into a single domain operation without introducing separate business logic.

Although intentionally compact, the implementation demonstrates the same production-oriented principles discussed throughout the article: optimistic updates, automatic rollback, per-task concurrency, task-level feedback, accessible interactions, immutable updates, and derived rendering from a single source of truth. These behaviors are direct consequences of the architectural decisions rather than isolated React techniques.

Testing Strategy

The board is reliable only if asynchronous success, failure, and concurrency are verified as first-class behaviors.

The testing strategy therefore follows the same boundaries as the architecture: pure transformations are tested in isolation, mutation workflows are tested through the React interface, and a small set of end-to-end scenarios validates browser behavior.

Unit tests

Unit tests cover deterministic logic that does not require rendering:

  • immutable task updates
  • combined search and assignee filtering
  • grouping tasks by status
  • transition validation
  • duplicate-mutation prevention

Because these operations are pure, their inputs and invariants can be verified directly and cheaply.

Integration tests

Integration tests provide the highest value because they exercise the complete mutation lifecycle from the user’s perspective.

They should verify that:

  • status changes appear optimistically before persistence completes
  • successful requests commit the new state
  • failed requests roll the task back and expose a contextual error
  • only the affected task becomes unavailable while pending
  • different tasks can update concurrently
  • repeated mutations for the same pending task are rejected
  • filters remain correct during optimistic transitions
  • invalid drops produce no mutation
  • pending and error states expose the expected accessibility semantics

These tests focus on observable behavior rather than internal state setters or implementation details.

End-to-end tests

End-to-end coverage should remain selective and target behavior that depends on the real browser environment:

  • button-based and drag-and-drop movement
  • optimistic feedback under network latency
  • rollback after API failure
  • concurrent task updates
  • filtering during active mutations
  • keyboard navigation and focus stability
  • responsive behavior across representative viewport sizes

This layer is slower and more expensive, so it should validate critical workflows rather than reproduce the entire integration suite.

Architectural consequence

The strongest test boundary is the mutation pipeline.

Unit tests verify the transformations around it, integration tests verify its behavior through the UI, and end-to-end tests confirm that the same workflow survives real browser interaction.

This layered strategy provides confidence in optimistic updates, rollback, concurrency, filtering, and accessibility without coupling the suite to the internal structure of individual components.

Production Evolution

The implementation presented in this article intentionally focuses on architectural correctness rather than feature completeness. It demonstrates the core patterns behind optimistic updates, rollback, derived state, and concurrent mutations while keeping the implementation approachable.

A production Kanban system would extend these foundations without changing the underlying architecture.

Press enter or click to view image in full size

Backend integration

The current implementation can evolve from a mocked API to a real backend backed by PostgreSQL. Shared API contracts, persistent storage, and authentication allow the same optimistic mutation pipeline to synchronize with server state while preserving the frontend architecture.

Collaboration and consistency

As multiple users interact with the same board, the system must address optimistic concurrency, request versioning, and real-time synchronization through WebSockets or Server-Sent Events. Conflict detection then becomes part of the synchronization layer, while the presentation layer continues to operate on the same mutation pipeline.

Richer board management

Production systems typically introduce multiple boards, custom columns, task ordering, assignees, permissions, and board-level administration. Although these features expand the domain model, they build on the same principles of centralized mutations and derived views.

Scalability

As datasets grow, rendering and data synchronization become the primary concerns. Virtualized rendering, server-side filtering and pagination, normalized data structures, and caching libraries such as TanStack Query can be introduced incrementally without redesigning the state model.

Reliability

A production application also requires retry mechanisms, request cancellation, comprehensive testing, CI/CD, monitoring, and structured observability. These capabilities improve operational reliability without affecting the ownership boundaries established by the architecture.

Architectural consequence

The most important observation is that these capabilities extend the implementation rather than replace it. The separation between confirmed state, optimistic state, derived views, and the shared mutation pipeline provides stable boundaries that allow the application to evolve from a frontend demonstration into a production-ready full-stack system with minimal architectural change.

Conclusion

A Kanban board is easy to underestimate because its complexity is hidden behind a familiar interface.

The visible problem is moving cards between columns. The architectural problem is preserving consistency while optimistic updates, asynchronous persistence, concurrent mutations, filtering, rollback, and accessibility all interact.

The implementation remains reliable because each concern has a clear boundary:

  • confirmed state represents what the server has accepted
  • optimistic state represents unresolved user intent
  • pending and error state remain scoped to individual tasks
  • columns and filtered results are derived rather than duplicated
  • every interaction converges on one mutation pipeline

These decisions reduce the number of invalid states the application can enter and keep failures local. They also create stable extension points for retries, ordering, permissions, real-time synchronization, virtualization, server-side queries, and persistent backend integration.

The broader lesson extends beyond Kanban boards. Interactive frontend systems become difficult to maintain when temporary UI assumptions, persisted data, request state, and derived views are allowed to blur together.

Reliable frontend architecture begins by separating those responsibilities before choosing libraries or optimizing components.

A Kanban board is therefore not primarily a drag-and-drop problem. It is a state consistency problem expressed through a drag-and-drop interface.

Once that state model is correct, the React implementation becomes a direct consequence of the architecture rather than a collection of disconnected techniques.

--

--