DEV Community

Cover image for React + Go: What I Practiced Building a 'Simple' Mini Kanban
luis-botelho
luis-botelho

Posted on

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}
  >
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)