Master Video Feedback: A Guide to Using GoReact Effectively

Stop Building “Go React” Apps Like It’s 2015

I once spent thirty-six hours straight in a windowless “war room” because of a single http.ResponseWriter that didn’t have its header set correctly. We were running a Go-based microservice that served a React dashboard for a Tier-1 telco. The React app was making thousands of concurrent requests to a Go endpoint that was supposed to stream real-time telemetry. I hadn’t set a WriteTimeout on the Go server, and the React client had no retry-backoff logic. When the database latency spiked, the Go routines started piling up like a multi-car pileup on the I-95. The Kubelet started screaming about memory pressure, the OOM-killer stepped in and started executing processes at random, and the whole cluster went into a death spiral. I watched 40 nodes go dark in under three minutes.

That’s the reality of the “Go React” stack. It’s not the clean, “it just works” experience that the Medium articles promise you. It’s a high-performance, high-stakes marriage between a language that hates magic and a frontend ecosystem that is built on top of it. If you’re here for a “Hello World” tutorial, go find a YouTube video. If you want to know how to build a Go and React system that doesn’t wake you up at 3:00 AM because of a leaked context or a CORS mismatch, keep reading.

The Documentation Gap: Why “Go React” Tutorials Are Lying to You

Most documentation for “Go React” setups assumes you’re running everything on localhost:8080 and localhost:3000. They tell you to use cors.AllowAll() and call it a day. In the real world, that’s a security audit failure waiting to happen. They also ignore the most painful part of this stack: the contract. Go is strictly typed. TypeScript (which you should be using with React) is strictly typed. Yet, people still pass raw interface{} or any types across the wire like it’s 2005. You end up with a panic: runtime error: invalid memory address on the backend or a “Cannot read property ‘id’ of undefined” on the frontend.

The “hype” says Go is fast and React is reactive. The “truth” is that your system is only as fast as your serialization layer and only as reactive as your state management allows. Most people over-engineer the React side with Redux and under-engineer the Go side by ignoring http.Server configurations. We’re going to fix that. We’re going to build a stack that uses Go 1.22’s new routing capabilities, Vite for the frontend, and a shared schema that ensures neither side is guessing what the data looks like.

Pro-tip: Stop using create-react-app. It’s been dead for years. If you’re still using it, you’re shipping 50MB of node_modules bloat that you don’t need. Use Vite. It’s faster, the HMR (Hot Module Replacement) actually works, and it doesn’t feel like it’s fighting your Go server for control of the port.

The Backend: Go 1.22 and the Death of Third-Party Routers

For years, the Go community told you that you needed Gorilla Mux or Chi to do basic routing. As of Go 1.22, the standard library net/http package finally grew up. You can now define methods and path parameters directly in the pattern string. This is a massive win for SREs because it means one less dependency to audit for vulnerabilities. Here is how you actually structure a production-grade Go entry point for a React app.

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "time"
)

func main() {
    // Use slog for structured logging. JSON by default for easy ingestion into ELK/Loki.
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    mux := http.NewServeMux()

    // Pattern matching in Go 1.22
    mux.HandleFunc("GET /api/v1/telemetry/{id}", handleGetTelemetry)
    mux.HandleFunc("POST /api/v1/telemetry", handlePostTelemetry)

    // Serve the React static files
    fs := http.FileServer(http.Dir("./dist"))
    mux.Handle("/", fs)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      loggingMiddleware(mux),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Graceful shutdown logic. Don't just kill the process.
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("listen_error", "err", err)
            os.Exit(1)
        }
    }()

    slog.Info("server_started", "port", 8080)

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt)
    <-stop

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        slog.Error("shutdown_failed", "err", err)
    }
    slog.Info("server_stopped")
}

Notice the ReadTimeout and WriteTimeout. If you don't set these, a malicious or poorly written React client can hold a connection open forever, eventually hitting your ulimit and preventing new connections. I’ve seen this happen with "long polling" implementations that forgot to set a heartbeat. The server just sits there, holding onto memory for a client that died three hours ago.

  • Use slog: Stop using fmt.Println or the standard log package. You need structured logs (JSON) so you can query them in Grafana. If you can't filter by trace_id, you're flying blind.
  • Graceful Shutdown: When you push to prod, Kubernetes sends a SIGTERM. If you don't catch it and finish your active DB queries, you end up with corrupted state or 502s during every deployment.

The Frontend: React Without the Fluff

React has become a bloated mess of "state management" libraries. You don't need Redux. You probably don't even need Context for 90% of your app. What you need is a way to handle server state. This is where TanStack Query (formerly React Query) comes in. It handles caching, retries, and loading states, which are the things that actually break in a Go React app.

Your React components should be "dumb." They should receive data and render it. The logic of *how* that data is fetched and *when* it is refreshed should live in hooks. Here is a real-world example of a hook that talks to our Go backend, including a custom error handler that actually tells the user what went wrong instead of just saying "Error."

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

const API_BASE = 'http://localhost:8080/api/v1';

interface Telemetry {
  id: string;
  value: number;
  status: 'online' | 'offline';
}

export const useTelemetry = (id: string) => {
  return useQuery<Telemetry, Error>({
    queryKey: ['telemetry', id],
    queryFn: async () => {
      try {
        const { data } = await axios.get(`${API_BASE}/telemetry/${id}`);
        return data;
      } catch (err) {
        if (axios.isAxiosError(err) && err.response) {
          // Log the specific X-Request-ID from Go for debugging
          console.error(`Request failed: ${err.response.headers['x-request-id']}`);
          throw new Error(err.response.data.message || 'Backend failure');
        }
        throw new Error('Network partition or DNS failure');
      }
    },
    retry: (failureCount, error) => {
      // Don't retry on 404s or 403s. It's a waste of resources.
      if (error.message.includes('404') || error.message.includes('403')) return false;
      return failureCount < 3;
    },
    staleTime: 5000, // Data is fresh for 5 seconds
  });
};

The staleTime is crucial. Without it, every time your user clicks a tab and comes back, React Query will refetch. If you have 1,000 users, that’s 1,000 unnecessary hits to your Go backend. Your DB will thank you for those 5 seconds of breathing room.

The Bridge: Shared Types or Bust

The biggest "fuck-up" I see in Go React projects is the manual re-typing of structs. You define a User struct in Go, then you manually define a User interface in TypeScript. Two weeks later, you change CreatedAt from a string to an integer in Go, forget to update the frontend, and the React app crashes because date-fns can't parse an integer.

Don't do this. Use a tool like tygo or sqlc with a custom generator to produce TypeScript definitions from your Go source of truth. Or better yet, use OpenAPI (Swagger). I know, I know, YAML is a nightmare, but having a single openapi.yaml file that generates your Go chi/mux boiler-plate AND your React hooks is the only way to scale without losing your mind.

// Go Struct
type TelemetryResponse struct {
    ID        string    `json:"id"`
    Value     float64   `json:"value"`
    UpdatedAt time.Time `json:"updated_at"`
}

// Generated TypeScript (via tygo)
export interface TelemetryResponse {
  id: string;
  value: number;
  updated_at: string; // Note: Go time.Time becomes ISO string
}

Note to self: Always use ISO8601 for dates between Go and React. Go’s time.RFC3339 is compatible with JavaScript’s new Date(). If you start passing Unix timestamps as raw integers, you’ll eventually run into a timezone offset bug that will take three days to debug because someone’s browser is in Asia/Kolkata and the server is in UTC.

The "Real World" Gotcha: Context Cancellation

Here is something they don't tell you in the Go React tutorials: what happens when a user navigates away from a page while a heavy Go process is running? In a naive implementation, the Go routine keeps running. It keeps querying the database, processing data, and consuming CPU, even though the React client has already closed the connection and doesn't care about the result anymore.

In Go, you must respect r.Context(). If the client disconnects, the context is cancelled. If your database driver supports it (and pgx or sql does), the query will be killed immediately. This is the difference between a system that handles 10,000 users and one that falls over at 500.

func handleGetTelemetry(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    // Simulate a heavy DB query
    ctx := r.Context()

    data, err := db.QueryContext(ctx, "SELECT * FROM telemetry WHERE id = $1", id)
    if err != nil {
        if err == context.Canceled {
            slog.Info("client_disconnected_early", "id", id)
            return
        }
        http.Error(w, "Database error", http.StatusInternalServerError)
        return
    }
    // ... render JSON
}

If you aren't passing ctx to your database calls, you are leaking resources. Period. On the React side, ensure your useEffect or useQuery cleanup functions are properly aborting the fetch request using AbortController. Axios does this automatically if you set it up, but fetch requires manual intervention. If you don't abort, the browser might keep the socket open, and the Go server won't know the client is gone until the WriteTimeout hits.

CORS: The Silent Killer

You’re going to run into CORS issues. It’s a rite of passage. But instead of using a generic middleware that allows everything, be surgical. In production, your React app is likely served from the same domain as your API (e.g., app.company.com and app.company.com/api). In that case, you don't even need CORS. You can just serve the static files from Go.

But during development, Vite runs on localhost:5173 and Go runs on localhost:8080. Use a proxy in vite.config.ts instead of enabling CORS in Go. This keeps your Go code clean and mirrors the production environment more closely.

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        secure: false,
      },
    },
  },
});

This way, in your React code, you just fetch from /api/v1/telemetry. No absolute URLs. No CORS headers needed in Go. No "Preflight request failed" errors in the console. It just works.

Deployment: Docker Multi-Stage Builds

If your Docker image is larger than 200MB, you’re doing it wrong. A Go React app should be tiny. Use a multi-stage build to compile the React app, build the Go binary, and then shove them both into a distroless or alpine image. I prefer debian-slim because alpine uses musl instead of glibc, which can lead to weird DNS resolution bugs in Go when using CGO.

# Stage 1: Build React
FROM node:20-slim AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ .
RUN npm run build

# Stage 2: Build Go
FROM golang:1.22-bookworm AS backend-builder
WORKDIR /app
COPY . .
RUN go build -o server ./main.go

# Stage 3: Final Image
FROM debian:bookworm-slim
WORKDIR /root/
COPY --from=frontend-builder /app/dist ./dist
COPY --from=backend-builder /app/server .
EXPOSE 8080
CMD ["./server"]

This produces a single, hermetic image. No node_modules in production. No Go compiler in production. Just a binary and some static HTML/JS/CSS. This is how you achieve sub-second cold starts in a serverless environment or rapid scaling in a K8s cluster.

State Management: Stop Using Redux

I’ve seen so many Go React projects die under the weight of Redux boilerplate. You have a Go struct, a TypeScript interface, a Redux Action, a Redux Reducer, a Redux Saga/Thunk, and finally, a React component. To add one field to a table, you have to touch six files. It’s madness.

Use Zustand for global UI state (like "is the sidebar open?") and TanStack Query for server state. That’s it. Zustand is about 1KB, has zero boilerplate, and doesn't require a Provider.

import { create } from 'zustand';

interface UIStore {
  isSidebarOpen: boolean;
  toggleSidebar: () => void;
}

export const useUIStore = create<UIStore>((set) => ({
  isSidebarOpen: true,
  toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
}));

It’s simple, it’s fast, and it doesn't get in your way. When you combine this with Go’s simplicity, you actually start moving fast again, instead of spending all day wiring up "actions" and "dispatchers."

The Observability Trap

Finally, let’s talk about when things go wrong. If a user reports a bug in your React app, how do you trace it back to a specific Go log line? If you aren't passing a X-Request-ID from the frontend to the backend, you're guessing.

  1. Generate a UUID in the React app (or have your Load Balancer do it).
  2. Attach it as a header to every Axios request.
  3. In Go, use a middleware to pull that header and put it into the slog context.
  4. Return that same ID in the error response to the React app.

Now, when a user sees an error, they can send you a screenshot with a Request ID, and you can find the exact line in your logs in seconds. That is what being a Senior SRE is about. It’s not about the code; it’s about the "debuggability" of the system.

Go and React are a powerhouse combination, but only if you respect the boundaries between them. Go is your sturdy, boring, reliable foundation. React is your dynamic, fast-moving, and often chaotic interface. Don't try to make Go act like JavaScript, and don't try to make React as rigid as Go. Use types to bridge the gap, respect the network, and for the love of all that is holy, set your timeouts.

Stop chasing the latest "Go React Framework" and just use the standard library with a solid build tool. Your future self, who isn't being paged at 3:00 AM, will thank you.

Related Articles

Explore more insights and best practices:

Leave a Comment