5 Essentials for Modern Frontend Architecture

Quick insights to better design your frontend architecture

Ashan Fernando
Bits and Pieces

--

With the increasing complexity of web applications, the need for more efficient, scalable, and maintainable code with enhanced developer experience has become necessary.

Modern frontend architecture has evolved significantly to address these quality attributes, with developers adopting new architectural styles, patterns, tools, and practices.

1. Design Systems

A Modern Design System

Before implementing your frontend, it’s worth investing in creating a design system that's being used across the application. Most of these design systems are based on popular component libraries like Material UI, Chakra UI, and Headless UI.

However, a modern design system goes well beyond a component library. It includes a design language well understood by designers and developers. The UI components in a design system range from simple UI elements like buttons and text boxes to complex compositions like widgets and dialogs and handles theming, typography, icons, etc.

While developing the design system, you need to be able to develop, preview, and test each component independently in isolation. Each component should be well-documented for easy reference since it is highly reused across one or more applications.

2. Component Reuse

Frontend architecture is unique compared to the backend, which requires a consistent look and feel across the application. And the most natural way to achieve this is by heavily reusing components across different parts of the frontend.

Traditionally, we used to keep a shared directory to store common UI components that are being reused across the application.

src/
├── components/
│ ├── shared/
│ │ ├── NavBar.jsx // Shared navigation bar
│ │ ├── Footer.jsx // Shared footer component
│ │ ├── CustomButton.jsx // A button styled for multiple uses across the app
│ │ ├── CustomCard.jsx // A card component used in various places
│ │ └── ModalWrapper.jsx // A generic modal wrapper for reuse
│ ├── HomePage/
│ │ ├── HomePage.jsx // Main content and layout for the home page
│ │ └── HomeFeature.jsx // A specific feature/component used only on the home page
│ ├── AboutPage/
│ │ ├── AboutPage.jsx // Main content for the about page
│ │ └── TeamMembers.jsx // Component to display team members, specific to About page
│ ├── ContactPage/
│ │ ├── ContactPage.jsx // Main content for the contact page
│ │ └── ContactForm.jsx // A contact form specific to the Contact page
│ └── Dashboard/
│ ├── DashboardPage.jsx // Main dashboard page layout and content
│ ├── DashboardWidget.jsx // A widget component used within the Dashboard
│ └── StatsCard.jsx // A card to display stats, specific to the Dashboard
├── App.jsx // Main app component that includes routing
├── index.js // Entry point for the React application

And, as we all know, components in the shared directory get bigger and become more difficult to reuse with time. The simplest step to avoid this is by designing the project to reuse components seamlessly from the smallest element to the most complex page, adapting a design system.

src/
├── components/
│ ├── layout/
│ │ ├── NavBar.jsx
│ │ └── Footer.jsx
│ ├── ui/
│ │ ├── CustomButton.jsx
│ │ ├── CustomCard.jsx
│ │ └── ModalWrapper.jsx
│ ├── forms/
│ │ ├── TextFieldGroup.jsx
│ │ ├── SelectField.jsx
│ │ └── FormActions.jsx
│ ├── dataDisplay/
│ │ ├── UserList.jsx
│ │ ├── ProductCardGrid.jsx
│ │ └── ChartWrapper.jsx
│ └── navigation/
│ ├── Breadcrumbs.jsx
│ ├── DrawerMenu.jsx
│ └── TabsPanel.jsx
├── templates/
│ ├── MainLayout.jsx // Template for the main layout, including NavBar and Footer
│ └── DashboardLayout.jsx // A specialized layout for dashboard pages
├── pages/
│ ├── HomePage.jsx // Uses MainLayout template
│ ├── AboutPage.jsx // Uses MainLayout template
│ ├── ContactPage.jsx // Uses MainLayout template
│ ├── DashboardPage.jsx // Uses DashboardLayout template
│ └── ProfilePage.jsx // Uses MainLayout template, might include specific components like UserList

However, going beyond a certain scale, having a monolithic structure like this could limit developer productivity for several reasons.

  • Higher cognitive load to find components that need modifications.
  • After modifying components, to test and understand their impact across the project.
  • In enterprises, reusing these components across multiple applications is difficult.
  • Developing and testing each component required to run the full application. Therefore, developers may need to perform a couple of actions to load the relevant UI.

Addressing these requires a flexible approach to restructure frontend projects into a composable platform using independent components. It makes the frontend architecture flexible to be shaped into different project structures and architectural styles in the long run.

3. Asset Optimization

Optimizing various types of assets in frontend applications is crucial for enhancing user experience. Additionally, it is important to minimize load time for public-facing web applications to improve SEO. However, the main challenge here is that different assets require specific optimization strategies:

1. Images

Raster Images: For JPEG, PNG, GIF, and WebP formats, use compression tools to reduce file size without significantly impacting visual quality. Consider using next-gen formats like WebP for better compression and quality. Implement responsive images with srcset to serve the right image size based on the user’s device.

Vector Images: SVG files are inherently scalable and usually smaller in size. Minify SVG files and consider inline SVG for icons to reduce HTTP requests.

2. JavaScript

Libraries and Frameworks: Minimize the use of heavy libraries and frameworks. Use tree shaking to remove unused code from your bundle. Bundle and minify your JavaScript files, and use code splitting to load code on demand.

Custom Scripts: Minify and compress (using Gzip or Brotli) your custom JavaScript or TypeScript files. Employ lazy loading for scripts that are not immediately necessary.

You can also use HTTP/2 to load resources more efficiently.

3. CSS

CSS Files: Minify CSS files to reduce their size. Use CSS compression and consider critical CSS techniques to load essential styles inline in order to render content faster.

Utilize PostCSS or similar tools to auto-prefix and optimize CSS for cross-browser compatibility.

4. Fonts and Icons

Web Fonts: Choose font formats like WOFF2 for better compression. Use font-display: swap to ensure text remains visible during font loading. Limit the number of font variations and only load the characters you need.

Icon Files: For icons, SVG sprites or icon fonts can be efficient. Ensure SVGs are minified, and consider using icon components for frameworks like React to inline SVGs, reducing the number of HTTP requests.

5. Video and Audio

Video Files: Compress video files and use modern formats like WebM for better quality at lower bitrates. Implement lazy loading for videos and consider using placeholder images until the user interacts.

Audio Files: Compress audio files and use formats like AAC for a good balance of file size and quality. Load audio files on demand to save bandwidth.

6. Documents

PDFs, Word Documents, Excel Sheets: Compress document files and consider loading them only upon user request to save initial loading time.

These optimizations can be done on two levels. The first option is to do the optimizations at build time. Typically, you can do assets like JavaScript, CSS, and images while you bundle your code.

Performance Measurement and Optimization Tools

You can use Chrome Developer Console tools like Performance Insights, Network, and Lighthouse to analyze application performance and the impact of each asset type. These tools provide insights into areas for improvement and optimization strategies.

CSS: Employ CSS minification and use critical CSS to improve the perceived loading time. Consider using CSS modules or styled-components in frameworks like React for scope and optimization.

By focusing on these optimization strategies for each asset type, you can significantly improve your frontend application's loading times and overall performance, leading to a better user experience and potentially higher SEO rankings.

4. Caching at Different Levels

Caching is a crucial strategy in frontend architecture to enhance performance, reduce server load, and provide faster content delivery to the end-user. Implementing caching at various levels can significantly improve the responsiveness of a web application. Here are some key areas where caching can be applied:

Browser Caching

Static Assets: Configure HTTP cache headers (Cache-Control, Expires) for static assets like CSS, JavaScript, images, and fonts. This instructs the browser to store these files locally and reuse them on subsequent visits without re-fetching them from the server.

Service Workers: Use service workers to cache dynamic content and assets. This allows for fine-grained control over the cache and enables offline capabilities for web applications.

CDN Caching

Content Delivery Networks (CDNs): CDNs can cache your static assets closer to the user at edge locations, significantly reducing latency and improving load times. Ensure that your CDN configuration aligns with your cache invalidation strategy to serve the most up-to-date content.

DNS Caching

DNS Lookups: DNS caching, either at the browser level or by the operating system, stores the IP addresses of accessed domains. This reduces the DNS lookup time for subsequent requests to the same domain.

Application-Level Caching

Data Caching: Implement caching strategies within your application to store frequently accessed data, such as API responses or computed results. Libraries like react-query or Apollo Client (for GraphQL) provide built-in caching mechanisms to optimize data fetching and state management.

5. Optimistic Concurrency

Optimistic concurrency is a strategy used in frontend applications to enhance user experience by assuming that operations will succeed without waiting for server confirmation. This approach is particularly useful to avoid problems occurring due to concurrent actions performed by users. Let’s look at how we can implement it in practice.

Optimistic UI Updates

Immediate Feedback: Update the UI immediately after a user action without waiting for the server's response. For example, when a user posts a comment, display the comment as if it has already been posted successfully.

Reconciliation: In case the server operation fails, revert the optimistic changes and inform the user of the failure. This might involve removing the optimistically added comment and showing an error message.

The following React example demonstrates how immediate feedback and reconciliation are implemented.

import React, { useState } from 'react';

const CommentSection = () => {
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [error, setError] = useState('');

const handleSubmit = async (e) => {
e.preventDefault();
const commentId = Date.now(); // Using timestamp as a fake ID for simplicity
const optimisticComment = { id: commentId, text: newComment };

// Optimistically add the comment to the UI
setComments([...comments, optimisticComment]);
setNewComment('');

try {
// Simulate a server request with a delay
await new Promise((resolve, reject) => setTimeout(resolve, 1000));

// Here you would typically make a POST request to your server
// For this example, we'll randomly simulate a failure
if (Math.random() > 0.7) {
throw new Error('Failed to post comment');
}

// If the request succeeds, the comment is already in the UI
} catch (error) {
// If the request fails, remove the optimistic comment and show an error
setComments(comments.filter(comment => comment.id !== commentId));
setError('Failed to post your comment. Please try again.');
}
};

return (
<div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Write a comment..."
/>
<button type="submit">Post Comment</button>
</form>
<ul>
{comments.map(comment => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
</div>
);
};

export default CommentSection;

Conflict Handling

Versioning: Use version numbers or timestamps for data entities to identify conflicts when the server state does not match the optimistic changes made on the client side.

Resolution Strategies: Implement strategies to resolve conflicts, such as prompting the user to retry the action, merging changes, or overriding the server state with the client state.

To illustrate versioning and conflict resolution with React, let’s consider a scenario where users can edit a document or a post. Each edit increases the version number of the document. If a user tries to save changes based on an outdated version, the application will prompt them to either discard their changes or overwrite the latest version.

The following example will simulate fetching and updating data and how it happens in the frontend.

import React, { useState, useEffect } from 'react';

const DocumentEditor = () => {
const [document, setDocument] = useState({ content: '', version: 0 });
const [editContent, setEditContent] = useState('');
const [conflict, setConflict] = useState(false);

// Simulate fetching the document
useEffect(() => {
const fetchDocument = async () => {
// Simulate an API call
const doc = await new Promise((resolve) => setTimeout(() => resolve({ content: 'Initial content', version: 1 }), 100));
setDocument(doc);
setEditContent(doc.content);
};

fetchDocument();
}, []);

const handleSave = async () => {
// Simulate saving the document and incrementing the version
const saveDocument = async (doc) => {
// Simulate a version conflict
if (doc.version !== document.version) {
setConflict(true);
return;
}

// Simulate an API call to save the document
await new Promise((resolve) => setTimeout(resolve, 100));
setDocument({ ...doc, version: doc.version + 1 });
setConflict(false);
};

await saveDocument({ content: editContent, version: document.version });
};

const handleOverwrite = () => {
// Overwrite the server version with the client version, incrementing the version number
setDocument({ content: editContent, version: document.version + 1 });
setConflict(false);
};

return (
<div>
{conflict && (
<div>
<p>Conflict detected: The document has been updated by another user.</p>
<button onClick={handleOverwrite}>Overwrite Server Version</button>
</div>
)}
<textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} />
<button onClick={handleSave}>Save</button>
</div>
);
};

export default DocumentEditor;

Use Cases

Collaborative Environments: Optimistic concurrency allows for a seamless user experience by immediately reflecting local changes in applications like collaborative editing tools or chat applications.

Form Submissions: Apply optimistic updates when submitting forms, such as immediately showing the submitted data in the UI while processing the submission in the background.

Optimistic concurrency, when combined with proper error handling and user feedback mechanisms, can significantly enhance web applications' interactivity and perceived performance. However, it’s important to carefully design the system to handle conflicts and errors gracefully and maintain data integrity and user trust.

To find out more, check out this article:

Learn More

--

--