The Top Ten Rendering Patterns in Modern Web Development
How to render your website most optimally? This question has many answers, and this article introduces the ten most commonly used rendering design patterns applied by current frameworks, so you’ll be able to pick whatever suits you best.
Discover how at OpenReplay.com.
The landscape of web development has rapidly evolved in recent years, particularly within the realm of front-end development. The credit for this transformation largely goes to the countless frameworks and technologies that have emerged, each aiming to simplify and enhance the process of building engaging user interfaces. However, with the abundance of existing frameworks and the constant emergence of new ones, staying up-to-date with front-end trends has become an arduous task. It’s too easy for newcomers to feel overwhelmed, lost in the vast ocean of choices.
Rendering, transforming data and code into a visible and interactive user interface, is the challenge at the heart of front-end development. While most frameworks tackle this challenge similarly, often with cleaner approaches than their predecessors, a handful of frameworks have opted for radically new solutions. In this article, we will be studying the ten most commonly used rendering patterns employed by popular frameworks, and in doing so, both beginners and experts alike will gain not only a solid foundation for understanding the multitude of new and existing frameworks but also fresh insights into solving the rendering problem in our applications.
At the end of this article, you will:
- Have a fundamental understanding of the most common rendering patterns used in web development today
- Gain insight into the advantages and disadvantages of the different rendering patterns
- Know what rendering pattern and framework to use in your next big project
What are UI rendering patterns?
Rendering, in the context of front-end development, is converting data and code into HTML that is visible to the end user.UI rendering patterns refer to the various approaches that can be taken to implement the rendering process. These patterns outline different strategies for how this transformation occurs and how the resulting user interface is presented. As we shall soon discover, rendering may be done on a server or in a browser, partially or all at once depending on the pattern implemented.
Choosing the right rendering pattern is paramount for developers as it directly impacts the performance, cost, speed, scalability, user experience, and even the developer experience of a web application.
In this article, we will be looking at the top 10 rendering patterns listed below:
- Static Site
- Multi-Page Applications(MPA)
- Single Page Applications (with Client Side Rendering CSR)
- Server Side Rendering (SSR)
- Static Site Generation (SSG)
- Incremental Static Generation (ISG)
- Partial Hydration
- Island Architecture
- Resumability
- Streaming SSR
In each case, we will look at the concept of the rendering pattern, the benefits and drawbacks, the use cases, the relevant frameworks concerned, and a simple code example to drive the point home.
Code Example
In all cases, our code example will be a simple crypto price tracker with two pages.
- The first page will display the available coins
- The second page will display the prices of a particular coin on different exchanges obtained from the Coingecko API.
- The second page will also feature a dark and light mode.
- There may be slight variations in the implementation of the various frameworks.
The global CSS for all examples is below:
/* style.css or the name of the global stylesheet */
h1,
h2 {
color: purple;
margin: 1rem;
}
a {
color: var(--text-color);
display: block;
margin: 2rem 0;
}
body {
font-family: Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
}
.dark-mode {
--background-color: #333;
--text-color: #fff;
}
.light-mode {
--background-color: #fff;
--text-color: #333;
}
.toggle-btn{
background-color: yellow;
padding: 0.3rem;
margin: 1rem;
margin-top: 100%;
border-radius: 5px;
}
Static Site
Static Site is the original, most basic, and straightforward approach to UI rendering. It involves creating a website by simply writing HTML, CSS, and JavaScript. Once the code is ready, it is uploaded as static files to a hosting service (such as Netlify), and a domain name is pointed to it. On request using the URL, the static files are served directly to users without server-side processing. Static Site rendering is ideal for static websites with minimal interactivity and no dynamic content, like landing pages and documentation websites.
Pros
- Very simple
- Fast
- Cheap (No servers)
- SEO friendly
Cons
- Not suitable for data that changes frequently (dynamic data)
- Not suitable for interactive apps
- No direct database connection
- Requires manual update and re-upload when data changes
Relevant framework
Hugo
Jekyll
- HTML/CSS/Vanilla Javascript (No Framework)
Demo (HTML/CSS/JavaScript)
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Cryptocurrency Price App</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h1>Cryptocurrency Price App</h1>
<ol>
<li><a href="./btcPrice.html">Bitcoin </a></li>
<li><a href="./ethPrice.html">Ethereum </a></li>
<li><a href="./xrpPrice.html">Ripple </a></li>
<li><a href="./adaPrice.html">Cardano </a></li>
</ol>
</body>
</html>
<!-- btcPrice.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h2>BTC</h2>
<ul>
<li id="binance">Binance:</li>
<li id="kucoin">Kucoin:</li>
<li id="bitfinex">Bitfinex:</li>
<li id="crypto_com">Crypto.com:</li>
</ul>
<script src="fetchPrices.js"></script>
<button class="toggle-btn">Toggle Mode</button>
<script src="darkMode.js"></script>
</body>
</html>
//fetchPrices.js
const binance = document.querySelector("#binance");
const kucoin = document.querySelector("#kucoin");
const bitfinex = document.querySelector("#bitfinex");
const crypto_com = document.querySelector("#crypto_com");
// Get the cryptocurrency prices from an API
let marketPrices = { binance: [], kucoin: [], bitfinex: [], crypto_com: [] };
async function getCurrentPrice(market) {
if (
`${market}` === "binance" ||
`${market}` === "kucoin" ||
`${market}` === "crypto_com" ||
`${market}` === "bitfinex"
) {
marketPrices[market] = [];
const res = await fetch(
`https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=bitcoin%2Cripple%2Cethereum%2Ccardano`
);
if (res) {
let data = await res.json();
if (data) {
for (const info of data.tickers) {
if (info.target === "USDT") {
let name = info.base;
let price = info.last;
if (`${market}` === "binance") {
marketPrices.binance = [
...marketPrices.binance,
{ [name]: price },
];
}
if (`${market}` === "kucoin") {
marketPrices.kucoin = [...marketPrices.kucoin, { [name]: price }];
}
if (`${market}` === "bitfinex") {
marketPrices.bitfinex = [
...marketPrices.bitfinex,
{ [name]: price },
];
}
if (`${market}` === "crypto_com") {
marketPrices.crypto_com = [
...marketPrices.crypto_com,
{ [name]: price },
];
}
}
}
}
}
}
}
async function findPrices() {
try {
const fetched = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
if (fetched) {
binance ? (binance.innerHTML += `${marketPrices.binance[0].BTC}`) : null;
kucoin ? (kucoin.innerHTML += `${marketPrices.kucoin[0].BTC}`) : null;
bitfinex
? (bitfinex.innerHTML += `${marketPrices.bitfinex[0].BTC}`)
: null;
crypto_com
? (crypto_com.innerHTML += `${marketPrices.crypto_com[0].BTC}`)
: null;
}
} catch (e) {
console.log(e);
}
}
findPrices();
//darkMode.js
const toggleBtn = document.querySelector(".toggle-btn");
document.addEventListener("DOMContentLoaded", () => {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
document.body.classList.add("dark-mode");
} else if (preferredMode === "light") {
document.body.classList.add("light-mode");
}
});
// Check the user's preferred mode on page load (optional)
function toggleMode() {
const body = document.body;
body.classList.toggle("dark-mode");
body.classList.toggle("light-mode");
// Save the user's preference in localStorage (optional)
const currentMode = body.classList.contains("dark-mode") ? "dark" : "light";
localStorage.setItem("mode", currentMode);
}
toggleBtn.addEventListener("click", () => {
toggleMode();
});
The code block above shows an implementation of our application using HTML/CSS/JavaScript. The application is below.
Page 1: All available coins are shown
Page 2: Prices of BTC on different exchanges obtained from the Coingecko API.
Note that the price page must be manually written for each coin when using a static site.
Multipage Applications (MPAs)
This rendering pattern emerged as a solution to handling dynamic data on our websites and led to the creation of many of the largest, most popular dynamic web applications today. With MPA, rendering is done by the server, which reloads to generate new HTML based on the current underlying data(usually from a database) for every incoming request whenever a request is made from a browser. That means the website can change in response to changes in the underlying data. The most common use cases are E-commerce sites, corporate firm web apps, and News company blogs.
Pros
- Simple and straightforward
- Handles dynamic data extremely well
- SEO friendly
- Good developer experience
- Highly Scalable
Cons
- Moderate support for UI interactivity
- Poor user experience due to multiple reloads
- Costly (Requires a server)
Relevant framework
Express
andEJS
(node.js)Flask
(python)Spring boot
(java)
Demo (Express
and EJS
)
npm i express and ejs
<!-- views/index.ejs -->
<!-- css file should be in public folder-->
<!DOCTYPE html>
<html>
<head>
<title>Cryptocurrency Price App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Cryptocurrency Price App</h1>
<ol>
<li><a href="./price/btc">Bitcoin </a></li>
<li><a href="./price/eth">Ethereum </a></li>
<li><a href="./price/xrp">Ripple </a></li>
<li><a href="./price/ada">Cardano </a></li>
</ol>
</body>
</html>
<!-- views/price.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Cryptocurrency Price App</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<h2><%- ID %></h2>
<ul>
<li id="binance">Binance:<%- allPrices.binance[0][ID] %></li>
<li id="kucoin">Kucoin:<%- allPrices.kucoin[0][ID] %></li>
<li id="bitfinex">Bitfinex:<%- allPrices.bitfinex[0][ID] %></li>
<li id="crypto_com">Crypto.com:<%- allPrices.crypto_com[0][ID] %></li>
</ul>
<button class="toggle-btn">Toggle Mode</button>
<script src="/darkMode.js"></script>
</body>
</html>
// public/darkMode.js
const toggleBtn = document.querySelector(".toggle-btn");
document.addEventListener("DOMContentLoaded", () => {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
document.body.classList.add("dark-mode");
} else if (preferredMode === "light") {
document.body.classList.add("light-mode");
}
});
// Check the user's preferred mode on page load (optional)
function toggleMode() {
const body = document.body;
body.classList.toggle("dark-mode");
body.classList.toggle("light-mode");
// Save the user's preference in localStorage (optional)
const currentMode = body.classList.contains("dark-mode") ? "dark" : "light";
localStorage.setItem("mode", currentMode);
}
toggleBtn.addEventListener("click", () => {
toggleMode();
});
// utils/fetchPrices.js
async function getCurrentPrice(market) {
let prices = [];
if (
`${market}` === "binance" ||
`${market}` === "kucoin" ||
`${market}` === "crypto_com" ||
`${market}` === "bitfinex"
) {
const res = await fetch(
`https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=bitcoin%2Cripple%2Cethereum%2Ccardano`
);
const data = await res.json();
for (const info of data.tickers) {
if (info.target === "USDT") {
let name = info.base;
let price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
}
module.exports = getCurrentPrice;
//app.js.
const getCurrentPrice = require("./utils/fetchPrices");
const express = require("express");
const ejs = require("ejs");
const path = require("path");
const app = express();
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(express.static("public"));
app.get("/", (req, res) => {
res.render("index");
});
app.get("/price/:id", async (req, res) => {
let { id } = req.params;
let ID = id.toUpperCase();
let allPrices;
try {
const fetched = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
if (fetched) {
allPrices = {};
allPrices.binance = fetched[0];
allPrices.kucoin = fetched[1];
allPrices.bitfinex = fetched[2];
allPrices.crypto_com = fetched[3];
console.log(allPrices);
res.render("price", { ID, allPrices });
}
} catch (e) {
res.send("server error");
}
});
app.listen(3005, () => console.log("Server is running on port 3005"));
Note: Here, each of the pages will be automatically generated by the server, unlike the static site, where each file must be written manually.
Single Page Applications (SPA)
Single Page Applications (SPA) are the solution of the 2010s to creating highly interactive web applications, and they continue to be used for such today. Here the SPA handles rendering by fetching an HTML shel (empty HTML page) and the javascript bundle from the server to the browser. In the browser, it then hands over control (hydrates) to javascript that dynamically inject (render) content to the shell. In this case, rendering is performed on the client side (CSR). Using Javascript, these SPAs are capable of heavily manipulating the content on that single page without requiring a full page reload. They also create the illusion of multiple pages by manipulating the URL bar to indicate each resource loaded onto the shell. The common use cases are web applications like project management systems, collaboration platforms, social media web apps, interactive dashboards, or document editors, which benefit from the responsive and interactive nature of SPAs.
Pros
- Highly Interactive
- Seamless user experience in navigating multiple pages
- Mobile friendly
Cons
- Slow loading time due to large javascript bundle
- Poor SEO capabilities
- High-security risk due to code execution on the client
- Poor scalability
Relevant framework
React
Angular
Vue
Demo (React
and React-router
)
// pages/index.jsx
import { Link } from "react-router-dom";
export default function Index() {
return (
<div>
<h1>Cryptocurrency Price App</h1>
<ol>
<li>
<Link to="./price/btc">Bitcoin </Link>
</li>
<li>
<Link to="./price/eth">Ethereum </Link>
</li>
<li>
<Link to="./price/xrp">Ripple </Link>
</li>
<li>
<Link to="./price/ada">Cardano </Link>
</li>
</ol>
</div>
);
}
//pages/price.jsx
import { useParams } from "react-router-dom";
import { useEffect, useState, useRef, Suspense } from "react";
import Btn from "../components/Btn";
export default function Price() {
const { id } = useParams();
const ID = id.toUpperCase();
const [marketPrices, setMarketPrices] = useState({});
const [isLoading, setIsLoading] = useState(true);
const containerRef = useRef(null);
function fetchMode() {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
containerRef.current.classList.add("dark-mode");
} else if (preferredMode === "light") {
containerRef.current.classList.add("light-mode");
}
}
useEffect(() => {
fetchMode();
}, []);
async function getCurrentPrice(market) {
const res = await fetch(
`https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=ripple%2Cbitcoin%2Cethereum%2Ccardano`
);
const data = await res.json();
const prices = [];
for (const info of data.tickers) {
if (info.target === "USDT") {
const name = info.base;
const price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
useEffect(() => {
async function fetchMarketPrices() {
try {
const prices = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
const allPrices = {
binance: prices[0],
kucoin: prices[1],
bitfinex: prices[2],
crypto_com: prices[3],
};
setMarketPrices(allPrices);
setIsLoading(false);
console.log(allPrices); // Log the fetched prices to the console
} catch (error) {
console.log(error);
setIsLoading(false);
}
}
fetchMarketPrices();
}, []);
return (
<div className="container" ref={containerRef}>
<h2>{ID}</h2>
{isLoading ? (
<p>Loading...</p>
) : Object.keys(marketPrices).length > 0 ? (
<ul>
{Object.keys(marketPrices).map((exchange) => (
<li key={exchange}>
{exchange}: {marketPrices[exchange][0][ID]}
</li>
))}
</ul>
) : (
<p>No data available.</p>
)}
<Btn container={containerRef} />
</div>
);
}
//components/Btn.jsx
export default function Btn({ container }) {
function toggleMode() {
container.current.classList.toggle("dark-mode");
container.current.classList.toggle("light-mode");
// Save the user's preference in localStorage (optional)
const currentMode = container.current.classList.contains("dark-mode")
? "dark"
: "light";
localStorage.setItem("mode", currentMode);
}
// Check the user's preferred mode on page load (optional)
return (
<div>
<button
className="toggle-btn"
onClick={() => {
toggleMode();
}}
>
Toggle Mode
</button>
</div>
);
}
// App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Index from "./pages";
import Price from "./pages/Price";
const router = createBrowserRouter([
{
path: "/",
element: <Index />,
},
{
path: "/price/:id",
element: <Price />,
},
]);
function App() {
return (
<>
<RouterProvider router={router}></RouterProvider>
</>
);
}
export default App;
Static Site Generation (SSG)
Static Site Generation (SSG) is a rendering pattern that leverages the original static site pattern of building websites. Here all the possible web pages from the source code are pre-built and rendered during the build process, generating static HTML files that are then stored in a storage bucket just like the static files would be originally uploaded in the case of a typical static site. On request to any of the routes that would have possibly existed based on the source code, a corresponding pre-built static page is served to the client. Hence, Unlike SSR or SPAs, SSG doesn’t rely on server-side rendering or client-side JavaScript to render content dynamically. Instead, the content is generated ahead of time and can be cached and delivered to users with high performance. This is suitable for moderately interactive websites with data that does not change often, like portfolio sites, small blogs, or documentation sites.
Pros
- SEO friendly
- Fast page load
- High performance
- Improved Security (since code neither runs on the client nor the server)
Cons
- Limited Interactivity
- Rebuilds and re-uploads are required on data change
Relevant framework
Nextjs
(by default)Gatsby
Hugo
Jekyll
Demo (Nextjs
)
// components/Btn.js
export default function Btn({ container }) {
function toggleMode() {
container.current.classList.toggle("dark-mode");
container.current.classList.toggle("light-mode");
// Save the user's preference in localStorage (optional)
const currentMode = container.current.classList.contains("dark-mode") ? "dark" : "light";
localStorage.setItem("mode", currentMode);
}
// Check the user's preferred mode on page load (optional)
return (
<div>
<button className="toggle-btn" onClick={() => {toggleMode()}}>
Toggle Mode
</button>
</div>
);
}
// components/Client.js
"use client";
import { useEffect, useRef } from "react";
import Btn from "@/app/components/Btn";
import { usePathname } from "next/navigation";
export default function ClientPage({ allPrices }) {
const pathname = usePathname();
let ID = pathname.slice(-3).toUpperCase();
const containerRef = useRef(null);
function fetchMode() {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
containerRef.current.classList.add("dark-mode");
} else if (preferredMode === "light") {
containerRef.current.classList.add("light-mode");
}
}
useEffect(() => {
fetchMode();
}, []);
return (
<div className="container" ref={containerRef}>
<h2>{ID}</h2>
{Object.keys(allPrices).length > 0 ? (
<ul>
{Object.keys(allPrices).map((exchange) => (
<li key={exchange}>
{exchange}: {allPrices[exchange][0][ID]}
</li>
))}
</ul>
) : (
<p>No data available.</p>
)}
<Btn container={containerRef} />
</div>
);
}
//price/[id]/page.js
import ClientPage from "../../components/Client";
async function getCurrentPrice(market) {
const res = await fetch( `https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=ripple%2Cbitcoin%2Cethereum%2Ccardano`
);
console.log("fetched");
const data = await res.json();
const prices = [];
for (const info of data.tickers) {
if (info.target === "USDT") {
const name = info.base;
const price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
export default async function Price() {
async function fetchMarketPrices() {
try {
const prices = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
const allPrices = {
binance: prices[0],
kucoin: prices[1],
bitfinex: prices[2],
crypto_com: prices[3],
};
return allPrices;
// Log the fetched prices to the console
} catch (error) {
console.log(error);
}
}
const allPrices = await fetchMarketPrices();
return (
<div>
{allPrices && Object.keys(allPrices).length > 0 ? (
<ClientPage allPrices={allPrices} />
) : (
<p>No data available.</p>
)}
</div>
);
}
//page.js
import Link from "next/link";
export default function Index() {
return (
<div>
<h1>Cryptocurrency Price App</h1>
<ol>
<li>
<Link href="./price/btc">Bitcoin </Link>
</li>
<li>
<Link href="./price/eth">Ethereum </Link>
</li>
<li>
<Link href="./price/xrp">Ripple </Link>
</li>
<li>
<Link href="./price/ada">Cardano </Link>
</li>
</ol>
</div>
);
}
Server-Side Rendering (SSR)
Server-Side Rendering (SSR) is a rendering pattern that combines the abilities of mpa and spa in other to overcome the limitations of both. Here, the server generates the HTML content for a web page, populates it with dynamic data, and sends it to the client for display. On the browser, javascript can then take over on an already rendered page to add interactivity to the components on the page as it does in SPAs. SSR handles the rendering process on the server before delivering the complete HTML to the browser, unlike SPAs, which rely completely on client-side JavaScript rendering. SSR is particularly useful for applications that prioritize SEO, content delivery or have specific accessibility requirements like cooperate sites, news sites, and E-commerce
Pros
- Moderately Interactive
- SEO friendly
- Fast loading time
- Good support for dynamic data
Cons
- Complex to implement
- Cost (requires a server)
Relevant framework
Next.js
Nuxt.js
Demo (Nextjs
)
The code for the SSR implementation on NEXT.js is about the same as the SSG demo. Here, the only change is in the getCurrentPrice
function. Using the fetch API with the no-cache
option, the pages will not be cached; instead, the server will be required to create a new page on every request.
//price/[id]/page.js
async function getCurrentPrice(market)
const res = await fetch( `https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=ripple%2Cbitcoin%2Cethereum%2Ccardano`,
{ cache: "no-store" }
);
console.log("fetched");
const data = await res.json();
const prices = [];
for (const info of data.tickers) {
if (info.target === "USDT") {
const name = info.base;
const price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
Incremental Static Generation (ISG)
An incremental Static Generation is an approach to generating static websites that combines the benefits of static site generation with the ability to update and regenerate specific pages or sections of a website without rebuilding the entire site. ISG allows for automatic incremental updates, resulting in less time spent rebuilding entire apps and more efficient use of server resources by only requesting new data from the server when necessary. This is practical for International Multilingual sites, corporative websites, and publishing platform sites.
Pros
- Real-Time auto-update support on static sites
- Cost-effective
- SEO friendly
- Good performance and scalability
Cons
- Complexity in implementation
- Not suitable for highly dynamic data applications
Relevant framework
Next.js
Nuxt.js
Demo (Nextjs
)
The code for the ISR implementation on NEXT.js is about the same as the SSG demo. The only change is in the getCurrentPrice
function. Using the fetch API with the options that specify the conditions under which the data should be fetched from the server, the pages will automatically be updated when the criteria we define are met.
Here we say that the underlying data should be validated every 60 seconds and the UI updated to reflect any change noticed in the data.
//price/[id]/page.js
async function getCurrentPrice(market)
const res = await fetch( `https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=ripple%2Cbitcoin%2Cethereum%2Ccardano`,
{ next: { revalidate: 60 } }
);
console.log("fetched");
const data = await res.json();
const prices = [];
for (const info of data.tickers) {
if (info.target === "USDT") {
const name = info.base;
const price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
Partial Hydration
Partial hydration is a technique used in client-side rendering (CSR) frameworks to tackle the issue of slow loading times. Using this technique, CSR frameworks will selectively render and hydrate with interactivity only the most important portions of a web page first rather than the entire page. Eventually, when certain criteria are met, less important interactive components can be hydrated with their interactivity. It allows for more efficient use of resources and faster initial page rendering by prioritizing the hydration of critical or visible components while deferring the hydration of non-critical or below-the-fold components. Part hydration can benefit any complex CSR or SPA with multiple interactive components.
Pros
- Faster loading times due to reduced initial javascript bundle
- Performance improved
- Improved SEO
- Resource efficiency
Cons
- Increased complexity and code
- Possibility of inconsistent UIs
Relevant framework
React
Vue
Demo (React
)
//pages/price.jsx
import { useParams } from "react-router-dom";
import React, { useEffect, useState, useRef, Suspense } from "react";
const Btn = React.lazy(() => import("../components/Btn"));
import getCurrentPrice from "../utils/fetchPrices";
export default function Price() {
const { id } = useParams();
const ID = id.toUpperCase();
const [marketPrices, setMarketPrices] = useState({});
const [isLoading, setIsLoading] = useState(true);
const containerRef = useRef(null);
// Wrapper component to observe if it's in the viewport
const [inViewport, setInViewport] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
setInViewport(entry.isIntersecting);
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => {
if (containerRef.current) {
observer.unobserve(containerRef.current);
}
};
}, []);
function fetchMode() {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
containerRef.current.classList.add("dark-mode");
} else if (preferredMode === "light") {
containerRef.current.classList.add("light-mode");
}
}
useEffect(() => {
fetchMode();
}, []);
useEffect(() => {
async function fetchMarketPrices() {
try {
const prices = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
const allPrices = {
binance: prices[0],
kucoin: prices[1],
bitfinex: prices[2],
crypto_com: prices[3],
};
setMarketPrices(allPrices);
setIsLoading(false);
console.log(allPrices); // Log the fetched prices to the console
} catch (error) {
console.log(error);
setIsLoading(false);
}
}
fetchMarketPrices();
}, []);
return (
<div className="container" ref={containerRef}>
<h2>{ID}</h2>
{isLoading ? (
<p>Loading...</p>
) : Object.keys(marketPrices).length > 0 ? (
<ul>
{Object.keys(marketPrices).map((exchange) => (
<li key={exchange}>
{exchange}: {marketPrices[exchange][0][ID]}
</li>
))}
</ul>
) : (
<p>No data available.</p>
)}
{inViewport ? (
// Render the interactive component only when it's in the viewport
<React.Suspense fallback={<div>Loading...</div>}>
<Btn container={containerRef} />
</React.Suspense>
) : (
// Render a placeholder or non-interactive version when not in the viewport
<div>Scroll down to see the interactive component!</div>
)}
</div>
);
}
In the above demo, the interactive component of our code, the Btn
component, located at the bottom of the page, will only be hydrated with interactivity when it comes into the viewport.
Island Architecture (with Astro
)
Island architecture is a promising UI rendering pattern championed by the developers of the Astro framework. The web app is divided into minor independent components called islands on the server. Each island is responsible for rendering a specific part of the application UI, and they can be rendered independently of each other. After being split into islands on the server, these multiple island bundles are shipped to the browser, where the framework then uses an extremely powerful form of partial hydration which has javascript taking over only components with interactive parts and enabling their interactivity while the rest of the applications non-interactive components remain as static. The most common use case is in building content-rich websites. Astro is a good choice for building websites that focus on content, such as blogs, portfolios, and documentation sites. Astro’s island architecture pattern can help improve the performance of these websites, especially for users with slow internet connections.
Pros
- Performance(One of the fastest frameworks of today)
- Smaller bundle size
- Easy to learn and maintain
- Good SEO performance
- Good developer experience
Cons
- Limited interactivity
- Difficulty debugging due to the extreme number of components
Relevant framework
Astro
Demo (Astro
)
---
// components/Btn.astro
---
<div>
<button class="toggle-btn"> Toggle Mode</button>
</div>
<script>
const toggleBtn = document.querySelector(".toggle-btn");
document.addEventListener("DOMContentLoaded", () => {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
document.body.classList.add("dark-mode");
} else if (preferredMode === "light") {
document.body.classList.add("light-mode");
}
});
// Check the user's preferred mode on page load (optional)
function toggleMode() {
const body = document.body;
body.classList.toggle("dark-mode");
body.classList.toggle("light-mode");
// Save the user's preference in localStorage (optional)
const currentMode = body.classList.contains("dark-mode") ? "dark" : "light";
localStorage.setItem("mode", currentMode);
}
toggleBtn.addEventListener("click", () => {
toggleMode();
});
</script>
---
// pages/[coin].astro
import Layout from "../layouts/Layout.astro";
import Btn from "../components/Btn.astro";
export async function getStaticPaths() {
return [
{ params: { coin: "btc" } },
{ params: { coin: "eth" } },
{ params: { coin: "xrp" } },
{ params: { coin: "ada" } },
];
}
const { coin } = Astro.params;
async function getCurrentPrice(market) {
const res = await fetch(
`https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=ripple%2Cbitcoin%2Cethereum%2Ccardano`
);
const data = await res.json();
const prices = [];
for (const info of data.tickers) {
if (info.target === "USDT") {
const name = info.base;
const price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
async function fetchMarketPrices() {
try {
const prices = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
const allPrices = {
binance: prices[0],
kucoin: prices[1],
bitfinex: prices[2],
crypto_com: prices[3],
};
return allPrices;
// Log the fetched prices to the console
} catch (error) {
console.log(error);
return null;
}
}
const allPrices = await fetchMarketPrices();
---
<Layout title="Welcome to Astro.">
<div>
<h2>{coin}</h2>
{
allPrices && Object.keys(allPrices).length > 0 ? (
<ul>
{Object.keys(allPrices).map((exchange) => (
<li>
{exchange}: {allPrices[exchange][0][coin]}
</li>
))}
</ul>
) : (
<p>No data available.</p>
)
}
<Btn />
</div>
</Layout>
---
//pages/index.astro
import Layout from "../layouts/Layout.astro";
---
<Layout title="Welcome to Astro.">
<main>
<div>
<h1>Cryptocurrency Price App</h1>
<ol>
<li>
<a href="./btc">Bitcoin</a>
</li>
<li>
<a href="./eth">Ethereum</a>
</li>
<li>
<a href="./xrp">Ripple</a>
</li>
<li>
<a href="./ada">Cardano</a>
</li>
</ol>
</div>
</main>
</Layout>
Resumability (with Qwik
)
Qwik is a meta-framework that handles rendering in a radically new way called reusability. This rendering pattern is based on two main strategies:
- Serialize the execution state of the application and the framework on the server and resume it on the client.
- Delay execution and download of JavaScript for as long as possible.
Hydration
This excerpt from the Qwik docs does a good job of introducing reusability.
”The best way to explain reusability is to understand how the current generation of frameworks is hydrated on the client. When an SSR/SSG application boots up on a client, it requires that the framework on the client restores three pieces of information:
Listeners - locate event listeners and install them on the DOM nodes to make the application interactive. Component tree - build an internal data structure representing the application component tree. Application state - restore any data fetched or saved in a store on the server. Collectively, this is known as hydration. All current generations of frameworks require this step to make the application interactive.
Hydration is expensive for two reasons:
- The frameworks must download all the component code associated with the current page.
- The frameworks have to execute the templates associated with the components on the page to rebuild the listener location and the internal component tree.
In serialization, Qwik
shows the ability to begin building a webpage on the server and continue executing the build on the client after the bundle has been shipped from the server, saving all the time other frameworks take to initiate hydration anew on the client.
On the part of lazy loading, Qwik
will ensure that the web applications load as fast as possible by extreme lazy loading where only necessary javascript bundles are loaded and the rest loaded in as they are needed. Qwik
does all this out of the box without many developer configurations.
This applies to complex publishing of blog apps and corporate websites.
Pros
- Resilience to network interruptions due to resumability
- Quick loading time
- Seo friendly
Cons
- Complex implementation
- Higher bandwidth usage
Relevant framework
Qwik
Demo (Qwik
)
//components/Btn.tsx
import { $, component$, useStore, useVisibleTask$ } from "@builder.io/qwik";
export default component$(({ container }) => {
const store = useStore({
mode: true,
});
useVisibleTask$(({ track }) => {
// track changes in store.count
track(() => store.mode);
container.value.classList.toggle("light-mode");
container.value.classList.toggle("dark-mode");
// Save the user's preference in localStorage (optional)
const currentMode = container.value.classList.contains("dark-mode")
? "dark"
: "light";
localStorage.setItem("mode", currentMode);
console.log(container.value.classList);
});
return (
<div>
<button
class="toggle-btn"
onClick$={$(() => {
store.mode = !store.mode;
})}
>
Toggle Mode
</button>
</div>
);
});
//components/Client.tsx
import { component$, useVisibleTask$, useSignal } from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";
import Btn from "./Btn";
export default component$(({ allPrices }) => {
const loc = useLocation();
const ID = loc.params.coin.toUpperCase();
const containerRef = useSignal<Element>();
useVisibleTask$(() => {
if (containerRef.value) {
const preferredMode = localStorage.getItem("mode");
if (preferredMode === "dark") {
containerRef.value.classList.add("dark-mode");
} else if (preferredMode === "light") {
containerRef.value.classList.add("light-mode");
}
}
});
return (
<div class="container" ref={containerRef}>
<h2>{ID}</h2>
{Object.keys(allPrices).length > 0 ? (
<ul>
{Object.keys(allPrices).map((exchange) => (
<li key={exchange}>
{exchange}: {allPrices[exchange][0][ID]}
</li>
))}
</ul>
) : (
<p>No data available.</p>
)}
<Btn container={containerRef} />
</div>
);
});
export const head: DocumentHead = {
title: "Qwik",
};
// routes/price/[coin]/index.tsx
import { component$, useVisibleTask$, useSignal } from "@builder.io/qwik";
import { type DocumentHead } from "@builder.io/qwik-city";
import Btn from "../../../components/Btn";
import Client from "../../../components/Client";
export default component$(async () => {
async function getCurrentPrice(market) {
const res = await fetch(
`https://api.coingecko.com/api/v3/exchanges/${market}/tickers?coin_ids=ripple%2Cbitcoin%2Cethereum%2Ccardano`
);
const data = await res.json();
const prices = [];
for (const info of data.tickers) {
if (info.target === "USDT") {
const name = info.base;
const price = info.last;
prices.push({ [name]: price });
}
}
return prices;
}
async function fetchMarketPrices() {
try {
const prices = await Promise.all([
getCurrentPrice("binance"),
getCurrentPrice("kucoin"),
getCurrentPrice("bitfinex"),
getCurrentPrice("crypto_com"),
]);
const allPrices = {
binance: prices[0],
kucoin: prices[1],
bitfinex: prices[2],
crypto_com: prices[3],
};
return allPrices;
// Log the fetched prices to the console
} catch (error) {
console.log(error);
}
}
const allPrices = await fetchMarketPrices();
return (
<div>
{allPrices && Object.keys(allPrices).length > 0 ? (
<Client allPrices={allPrices} />
) : (
<p>No data available.</p>
)}
</div>
);
});
export const head: DocumentHead = {
title: "Qwik Flower",
};
//routes/index.tsx
import { component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { Link } from "@builder.io/qwik-city";
export default component$(() => {
return (
<>
<div>
<h1>Cryptocurrency Price App</h1>
<ol>
<li>
<Link href="./price/btc">Bitcoin </Link>
</li>
<li>
<Link href="./price/eth">Ethereum </Link>
</li>
<li>
<Link href="./price/xrp">Ripple </Link>
</li>
<li>
<Link href="./price/ada">Cardano </Link>
</li>
</ol>
</div>
</>
);
});
export const head: DocumentHead = {
title: "Welcome to Qwik",
meta: [
{
name: "description",
content: "Qwik site description",
},
],
};
Streaming SSR
Streaming SSR is a relatively new technique for rendering web applications. Streaming SSR works by rendering the application UI on the server in chunks. Each chunk is rendered as soon as it is ready and then streamed to the client. The client displays and hydrates the chunks as they are received. This means that the client does not have to wait for the entire application to be rendered before it can start interacting with it. This improves the initial load time of web applications, especially for large and complex applications. Streaming SSR is best for very-large-scale applications like E-commerce and trading applications.
Pros
- Performance
- Live Updates
Cons
- Complexity
Relevant framework
Next.js
Nuxt.js
Demo
Unfortunately, our application is not complex enough to give a suitable example.
Summary
In this article, we explored the ten most popular UI rendering patterns in frontend web development today. In doing so, we discussed each approach’s strengths, limitations, and trade-offs. However, it is important to note that there is no one-size-fits-all rendering pattern or a universally perfect approach to rendering. Each application has its unique requirements and characteristics, making the selection of an appropriate rendering pattern crucial for the success of the development process.
References
Qwik
documentation for more information on resumability: https://qwik.builder.io/docs/concepts/think-qwik/ https://qwik.builder.io/docs/concepts/resumable/Astro
documentation for more information on island architecture https://docs.astro.build/en/getting-started/- Brief
Next.js
documentation on rendering https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic-rendering - A practical look at streaming SSR https://blog.logrocket.com/streaming-ssr-with-react-18/
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.