This content originally appeared on DEV Community and was authored by ELMay
In 2010, the web was simple. PHP, Rails, or a bit of jQuery on top of some Twig templates. The server rendered the page, you clicked a link, the browser fetched a new one. That was it.
It was predictable. Honest. And slow.
Then we saw the future — and it was running in the browser.
What if the client handled everything? No refreshes, no full reloads, just pure JavaScript bliss.
We sprinted into that future with React, Redux, Angular, and a thousand frameworks that promised to make the web feel alive.
And then, somewhere along the way, we started missing the simplicity we left behind.
Step 1: From Templates to Trees
Server templates were simple — and static. If you wanted interactivity, you reached for jQuery or hand-rolled AJAX calls. Here’s how a basic product list with a “like” button might have looked:
<ul id="products">
{% for item in products %}
<li>
{{ item.name }} <button class="like" data-id="{{ item.id }}">❤️</button>
</li>
{% endfor %}
</ul>
<script>
$(".like").on("click", function () {
const id = $(this).data("id");
$.ajax({
url: `/api/like/${id}`,
method: "POST",
success: () => {
$(this).text("💙"); // update DOM manually
},
error: () => alert("Error liking item")
});
});
</script>
It worked — but every bit of behavior meant mixing DOM access, server endpoints, and manual event wiring. You were writing an orchestra by hand.
React collapsed that chaos into components and state:
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
export function ProductList({ products }) {
const likeMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/like/${id}`, { method: "POST" });
if (!res.ok) throw new Error("Failed");
return id;
},
});
const [liked, setLiked] = useState<number[]>([]);
const handleLike = async (id: number) => {
await likeMutation.mutateAsync(id);
setLiked((prev) => [...prev, id]);
};
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<button onClick={() => handleLike(p.id)}>
{liked.includes(p.id) ? "💙" : "🤍"} {p.name}
</button>
</li>
))}
</ul>
);
}
With React and React Query, network logic, UI state, and DOM updates live together in one declarative system. Instead of imperatively wiring up selectors and callbacks, we declare what should happen.
From Direct DOM Manipulation → Virtual DOM
Classic jQuery code mutates the DOM directly:
// imperative: find, mutate, rebind
$("#counter").text(Number($("#counter").text()) + 1);
$("#like").toggleClass("active", liked);
React introduced the Virtual DOM and reconciliation:
- You describe the UI for a given state as a tree.
- React diffs (reconciles) the previous tree with the next one.
- Only the minimal DOM mutations are applied.
- Updates are batched and event handling is normalized across browsers.
This flips the model:
- jQuery: How do I change the DOM to match new state?
- React: Given state, what should the UI look like? (React figures out the changes.)
A tiny contrast:
// jQuery – update two places that depend on the same state
price = price + 1;
$("#price").text(price);
$("#summary .price").text(price);
// React – render once from state, VDOM calculates the minimal DOM edits
function Summary({ price }) {
return (
<>
<span id="price">{price}</span>
<div id="summary">
<span className="price">{price}</span>
</div>
</>
);
}
This shift from manual DOM diffs to declarative trees is why components scale: fewer incidental ties to selectors, fewer missed updates, and a single source of truth for rendering.
We didn’t just move rendering from the server to the client — we moved responsibility. Now the browser was in charge of logic, state, data fetching, and behavior.
We didn’t realize it yet, but we’d traded static predictability for dynamic complexity.
Step 2: The Rise of State Empires
Before Redux, Facebook introduced Flux — an architectural pattern described in their 2014 documentation. It formalized a unidirectional data flow: actions → dispatcher → stores → view. Redux was heavily inspired by Flux, taking its single-direction flow and reducing it to a pure function model. Flux introduced the idea that predictable state changes are the foundation for scalable UI logic.
Once rendering became dynamic, we needed to synchronize everything: UI, data, user actions, caches. We invented a whole new ecosystem to keep things in order. Redux, MobX, Context, Recoil, Zustand — all born from the same problem: our components were talking too much.
The Verbose Era — Classic Redux for one API call
To fetch a list and show loading/errors, you often wrote actions, reducer cases, and a thunk:
// actions.ts
export const PRODUCTS_REQUEST = "PRODUCTS_REQUEST" as const;
export const PRODUCTS_SUCCESS = "PRODUCTS_SUCCESS" as const;
export const PRODUCTS_FAILURE = "PRODUCTS_FAILURE" as const;
export const productsRequest = () => ({ type: PRODUCTS_REQUEST });
export const productsSuccess = (data: Product[]) => ({
type: PRODUCTS_SUCCESS,
payload: data,
});
export const productsFailure = (err: string) => ({
type: PRODUCTS_FAILURE,
error: err,
});
// thunks.ts
export const fetchProducts = () => async (dispatch: AppDispatch) => {
dispatch(productsRequest());
try {
const res = await fetch("/api/products");
const data = await res.json();
dispatch(productsSuccess(data));
} catch (e) {
dispatch(productsFailure(String(e)));
}
};
// reducer.ts
type State = { loading: boolean; data: Product[]; error?: string };
const initial: State = { loading: false, data: [] };
export function productsReducer(
state = initial,
action: AnyAction
): State {
switch (action.type) {
case PRODUCTS_REQUEST:
return { ...state, loading: true, error: undefined };
case PRODUCTS_SUCCESS:
return { loading: false, data: action.payload };
case PRODUCTS_FAILURE:
return { loading: false, data: [], error: action.error };
default:
return state;
}
}
// component.tsx
const Products = () => {
const dispatch = useDispatch();
const { data, loading, error } = useSelector(
(s: RootState) => s.products
);
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
if (loading) return <Spinner />;
if (error) return <Error msg={error} />;
return <List items={data} />;
};
That’s ~50–70 lines for a single GET.
The Upgrade — Redux Toolkit & RTK Query
RTK reduced ceremony, but still required API slices, store wiring, and tag invalidation:
// api.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Products"],
endpoints: (builder) => ({
getProducts: builder.query<Product[], void>({
query: () => "/products",
providesTags: ["Products"],
}),
like: builder.mutation<void, number>({
query: (id) => ({ url: `/like/${id}`, method: "POST" }),
invalidatesTags: ["Products"],
}),
}),
});
export const { useGetProductsQuery, useLikeMutation } = api;
// store.ts
export const store = configureStore({
reducer: { [api.reducerPath]: api.reducer },
middleware: (gDM) => gDM().concat(api.middleware),
});
// component.tsx
function Products() {
const { data = [], isLoading, error } = useGetProductsQuery();
const [like] = useLikeMutation();
if (isLoading) return <Spinner />;
if (error) return <Error />;
return data.map((p) => (
<button key={p.id} onClick={() => like(p.id)}>
{p.name}
</button>
));
}
Better… but you still manage cache keys and invalidation explicitly.
The Pivot — React Query (Server State, not App State)
Many teams realized most Redux state was actually server cache. React Query built the missing mental model: treat async data as cache with lifecycle.
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
function Products() {
const queryclient = useQueryClient();
const products = useQuery({
queryKey: ["products"],
queryFn: () => fetch("/api/products").then((r) => r.json()),
});
const like = useMutation({
mutationFn: (id: number) =>
fetch(`/api/like/${id}`, { method: "POST" }),
onSuccess: () =>
queryclient.invalidateQueries({ queryKey: ["products"] }),
});
if (products.isLoading) return <Spinner />;
if (products.isError) return <Error />;
return products.data.map((p: Product) => (
<button key={p.id} onClick={() => like.mutate(p.id)}>
{p.name}
</button>
));
}
Why the shift happened
- Most Redux stores were 60–90% async data and request status.
- We were hand-rolling caches, retries, deduping, and invalidation.
- React Query reframed this as server state with built-in policies (
staleTime,cacheTime,refetchOnWindowFocus, retries).
Rule of thumb
- If it comes from the server and can be refetched: it’s cache → React Query.
- If it’s purely UI or business state (wizard step, modal open, form draft): it’s app state → React state / context / small store.
And users? They finally got fast first paint without waiting on a JS state machine just to read a list.
Step 3: The Return of the Server
Eventually, we realized something: shipping megabytes of JavaScript just to render HTML felt backwards. The pendulum swung again.
React 16: modern SSR shows up
React had server rendering long before v16, but React 16 (2017) made it practical at scale: a new fiber architecture, better error handling, and renderToNodeStream for streaming HTML from the server. That turned SSR from a once-per-request snapshot into something that could progressively paint while the server kept working.
React 18: Suspense + streaming
React 18 took the next step: Suspense on the server and better streaming. Instead of blocking on slow data, the server can send a shell early and stream in the rest as it resolves. The client hydrates what arrives, when it arrives.
// Next.js (App Router) – streaming a slow section behind Suspense
export default function Page() {
return (
<main>
<Header />
<Suspense fallback={<SkeletonProducts />}>
{/* server component tree that may suspend */}
<Products />
</Suspense>
<CartWidget /> {/* client component */}
</main>
);
}
This model clarified responsibilities:
- The server can fetch, render, and stream HTML + RSC payloads as data resolves.
- The client hydrates interactive islands and handles local state/events.
Why React Server Components (RSCs)?
Once streaming and Suspense were in place, the next logical step was to let components run on the server by default and ship zero JS for them when they don’t need interactivity. That’s RSCs: server-first components that can render data, compose with client components at boundaries, and avoid sending unnecessary code to the browser.
// Server Component
export default async function ProductList() {
const products = await db.query("SELECT * FROM products");
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
);
}
// Client Component (island)
"use client";
export function ProductCard({ product }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked((v) => !v)}>
{liked ? "💙" : "🤍"} {product.name}
</button>
);
}
The line between client and server is explicit again — a boundary you can design around. The server fetches and streams; the client hydrates and interacts. We stopped asking where to render, and started asking what belongs where.
Step 4: Boundaries Are the New Abstractions
In the early web, the boundary was the HTTP request. In client-heavy apps, it was the component tree. Today, with Server Components, it’s both — a negotiation between network and interactivity.
Each layer takes ownership:
- The server renders data-rich UI and serializes context.
- The client handles state, input, and event-driven behavior.
They meet at the hydration boundary — the modern contract between performance and experience.
// Next.js app directory example
export default function Page() {
return (
<main>
<Products /> {/* server */}
<CartWidget /> {/* client */}
</main>
);
}
It’s not SSR vs CSR anymore. It’s hybrid by design.
Step 5: The Rise of Islands
Islands: a name for what we were feeling
By 2019–2020, the community started putting a name to a pattern many teams were converging on. Etsy’s frontend architect Katie Sylor-Miller coined the term component islands (2019). In 2020, Jason Miller (creator of Preact) popularized and documented it: render the whole page on the server, then hydrate only the dynamic parts as small, self-contained widgets. In other words: SSR for the canvas, CSR for the hotspots. This is the same idea people describe as partial / selective hydration.
Paraphrasing Miller: “Render HTML on the server and insert slots for dynamic regions that hydrate into small, isolated widgets on the client.”
Islands architecture
Frameworks like Astro, Qwik, and the new Next.js app router push this even further — rendering mostly on the server, but activating “islands” of interactivity in the client.
The server paints the world. The client wakes it up.
The irony? We’ve reinvented the simplicity of PHP templates — only this time, the boundaries are explicit and enforced by design.
Each island is self-contained. Deleting one doesn’t break the others. Adding one doesn’t rehydrate the whole ocean.
We finally learned that composability and isolation are the same thing seen from opposite ends.
Step 6: The Big Lesson
Every architectural shift starts as a rebellion and ends as a reconciliation.
We fled the server because we wanted power. We returned because we needed balance.
The point isn’t to choose between server and client — it’s to respect their boundaries.
The server is great at knowing. The client is great at feeling.
When both do their jobs, users stop noticing where the code runs — and start noticing that it just works.
After a decade of frameworks, state managers, and hydration loops, we’ve come full circle.
We didn’t just rediscover SSR.
We rediscovered discipline.
This content originally appeared on DEV Community and was authored by ELMay
ELMay | Sciencx (2025-11-24T17:48:13+00:00) The Long Way Home: How We Left the Server, Built a Client Empire, and Came Back Again. Retrieved from https://www.scien.cx/2025/11/24/the-long-way-home-how-we-left-the-server-built-a-client-empire-and-came-back-again/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.



