Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 3 min read

React + Go: What I Practiced Building a 'Simple' Mini Kanban

The Hook Not every challenge needs a database, a design system, or a microservice to be worth doing well. Veritas Consultoria Empresarial's Full Stack Challenge asked for a Mini Kanban โ€” React frontend, Go backend, in-

React + Go: What I Practiced Building a 'Simple' Mini Kanban

The Hook

Not every challenge needs a database, a design system, or a microservice to be worth doing well. Veritas Consultoria Empresarial's Full Stack Challenge asked for a Mini Kanban โ€” React frontend, Go backend, in-memory storage allowed. Simple scope, on purpose.

I used that room to practice the decisions that actually separate a demo from a delivery. Here's what's in the repo.

Isolating Storage Before You Need To

In-memory storage was explicitly allowed by the challenge. The lazy version is a global map somewhere in a handler file. Instead, every read and write goes through a MemoryTaskRepository:

type MemoryTaskRepository struct {
    mu    sync.RWMutex
    tasks map[string]domain.Task
}

func (r *MemoryTaskRepository) List() []domain.Task {
    r.mu.RLock()
    defer r.mu.RUnlock()

    tasks := make([]domain.Task, 0, len(r.tasks))
    for _, task := range r.tasks {
        tasks = append(tasks, task)
    }

    sort.SliceStable(tasks, func(i, j int) bool {
        return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
    })

    return tasks
}

func (r *MemoryTaskRepository) Save(task domain.Task) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.tasks[task.ID] = task
}

Two things worth unpacking:

Why sync.RWMutex? Go's net/http server runs every request in its own goroutine, and plain maps aren't safe for concurrent access. RWMutex lets multiple reads happen in parallel (RLock) while writes get serialized (Lock) โ€” so two simultaneous requests can't race and corrupt the map.

Why in-memory at all? Because it's behind a Repository interface, swapping the map for Postgres later means implementing one new struct with the same methods โ€” List, FindByID, Save, Delete โ€” not touching every handler in the codebase. The README documents this explicitly as a scope trade-off, data resets on restart, on purpose: it's scope, not an oversight.

Validation Lives With the Data, Not the Handler

func NewTask(id, title, description string, status TaskStatus) (*Task, error) {
    task := &Task{
        ID:          id,
        Title:       strings.TrimSpace(title),
        Description: strings.TrimSpace(description),
        Status:      status,
        CreatedAt:   time.Now().UTC(),
        UpdatedAt:   time.Now().UTC(),
    }

    if err := task.Validate(); err != nil {
        return nil, err
    }
    return task, nil
}

func (t *Task) Validate() error {
    t.Title = strings.TrimSpace(t.Title)
    t.Description = strings.TrimSpace(t.Description)

    if t.Title == "" {
        return ErrTaskTitleRequired
    }
    if !t.Status.IsValid() {
        return ErrInvalidTaskStatus
    }
    return nil
}

Both creation and update route through the same Validate(). Trim, required-field check, status check โ€” one place, applied consistently no matter which code path touches a Task. On the HTTP side, the decoder adds its own layer: DisallowUnknownFields() rejects payloads with unexpected fields instead of silently ignoring them.

Two Paths, One Handler

Drag-and-drop feels like the whole feature โ€” until you think about who can't use a mouse. Instead of treating keyboard/touch as an afterthought, TaskCard supports both from the start:

function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault();
    onOpen(task);
  }
}

return (
  <article
    className="task-card"
    role="button"
    tabIndex={0}
    draggable
    aria-label={`Abrir tarefa: ${task.title}`}
    onClick={handleOpen}
    onKeyDown={handleKeyDown}
    onDragStart={handleDragStart}
    onDragEnd={handleDragEnd}
  >

Tab focuses the card, Enter/Space opens it, and the form inside lets you change status without ever touching drag-and-drop. Both paths โ€” mouse drag and keyboard/form โ€” end up calling the exact same handleUpdateTask in the useTasks hook. The update logic has no idea, and doesn't need to know, which path triggered it:

async function handleUpdateTask(id: string, input: UpdateTaskInput) {
  try {
    setIsSubmitting(true);
    const updatedTask = await updateTask(id, input);
    setTasks((currentTasks) =>
      currentTasks.map((task) => (task.id === id ? updatedTask : task)),
    );
    setSuccessMessage(getTaskUpdatedMessage(previousTask, updatedTask));
    return true;
  } catch (err) {
    setError(err instanceof Error ? err.message : "Nรฃo foi possรญvel atualizar a tarefa.");
    return false;
  } finally {
    setIsSubmitting(false);
  }
}

What Shipped

  • โœ… Full CRUD wired to a REST API
  • โœ… Drag-and-drop between columns
  • โœ… Validation on frontend and backend
  • โœ… Loading, error, and feedback states
  • โœ… Keyboard navigation + accessible alternative to drag-and-drop
  • โœ… Backend organized into domain / handlers / repository
  • โœ… Backend tests (repository + domain)
  • โœ… One-command Docker Compose setup
  • โœ… README, User Flow, and Data Flow documented with Mermaid diagrams

The Actual Takeaway

None of this โ€” the Repository interface, the documented trade-offs, the accessible alternative โ€” was required to pass a demo. A hardcoded array and a console.log would've worked for the happy path. What pushed these decisions was thinking about who reads this code next: whoever tests it, runs it, extends it, or has to explain it out loud in an interview.

Thanks to Veritas Consultoria Empresarial for turning a selection process into an actual practice exercise.

Repo: https://github.com/luis-botelho/desafio-fullstack-veritas

Let's Discuss! ๐Ÿ‘‡

What's a "small" technical challenge where you ended up over-engineering (in a good way) just to practice a decision you don't get to make often?

golang #react #webdev #accessibility

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.