28 concepts of frontend development


· 27 min read

As a mainly frontend developer who is writing a frontend development blog, I must say, maybe with a small bias, that frontend development starts harder than most of backend development. It's an unavoidable distributed problem!

But honestly, one of the biggest paint points is that frontend tooling and concepts are usually lack documentation, and are often intermingled with each other. Usually, there are guides and documentation about specific tools, and not really about the concepts around them.

So, as any software developer, here I will present you my own convention of concepts and tools that I believe are important for frontend development. This post doesn't intend to cover historical context or libraries, but I highly recommend you to explore on your own.

Reusing pieces

1. Rendering frameworks

A library, tool, or language that renders UI to a screen.

There are many approaches to rendering with different underlying implementations, but what you will see most is the API difference:

Rendering solutions are usually integrated with other tools, but usually provide at minimum composability and reusability of UI, along with protection against XSS attacks.

2. Behavioral elements

Foundational pieces used to build UI.

The main goal of behavioral elements is to provide advanced UI primitives that can be used in different contexts while providing great accessibility and usability out of the box.

3. State systems

Systems to build reactive UIs.

The main goal of state systems should be to provide composable primitives that allow UI reactivity, ideally in a performant way. While manually tracking subscriptions or dependencies works, the developer experience and performance of implicit tracking tends to be superior in most cases.

4. Global state

Sharable and debuggable state at scale.

Some state systems can be used globally in apps. In the case of React, it isn't, and since it's the biggest rendering framework, it requires an external library. While a library isn't necessary on other frameworks, some concepts and tools can still be useful.

The main difference of these state systems is that this type of state management provides better tooling for debugging complex applications thanks to devtools and, in libraries that use stores, a framework for modeling state changes.

Server-client communication

5. Communication protocols

Underlying protocol of how messages are passed between the client and the server.

One could try to organize communication protocols by layer, but since they're usually hidden by an abstraction layer (the messaging protocol), I would argue that it doesn't matter. The only thing that matters is understanding the limitations of each protocol.

You can read a great comparison of protocols in the RxDB blog.

6. Messaging protocols

How data sent between the client and the server is structured.

While RPC is closest to native function calling, all messaging protocols could be thought of as function calls and callbacks, with specific interfaces, that the end developer uses to abstract away the underlying communication protocol.

7. External state management

How server-side data is managed in the client.

While data can be fetched by the messaging protocol, for a better experience for the developer and user, an additional layer can be added in between, that can handle:

While those can be implemented manually, it's better to use an external library which receives a function to fetch data and provides all of those utilities.

Some messaging libraries like relay manage those internally. The common agnostic libraries are tanstack query and redux toolkit query.

8. View routing

How the client navigates different views.

Data is only half of the equation; the client should also load the necessary assets and render the UI. Some SPAs can load the entire code like native applications, but in large applications, there is a need to gradually load only the necessary code and assets. Router frameworks usually provide:

Usually, routing frameworks are used with a router. Examples are nextjs, react router, tanstack router, nuxt, sveltekit, and angular router.

Styling

9. Naming and grouping styles (atomic, etc)

How class names are defined.

Naming CSS classes has historically been a large problem, since classes are global and can conflict with each other in a large application. To address this, different methodologies to name and group styles can be used.

Some options use a preprocessor to parse a custom syntax or extension of CSS, reducing the style complexity by adding compiled variables, nesting, and other features. Some examples are sass and the tailwindcss pre-processor.

10. Scoped styles (css modules, in-frameworks)

How class names are automatically scoped.

The naming problem is currently solved by automatically scoping classes in component systems and compilers.

While one could avoid using any of those solutions and rely on naming conventions, when building a component system or any component with complex CSS, it's better to have automatic scoping and the full power of CSS.

11. Style precedence

How styles are applied and overridden.

Sometimes, styles of class names could be reused across the application. For example, a button that has default styles.

.my-button {
  border: 1px solid black;
  height: 40px;
}

We might want to allow certain styles to be overridden, but others we might want to make it non-overridable. To do that, there are mainly 3 options:

12. Theming (css variables, user preferences)

How we can personalize the application's look.

Apps that use a relatively large set of styles and components need a way to declare design tokens to be the source of truth for the styles, allowing reusability and maintainability. Usually, behavioral elements with styles include mechanisms to overwrite their styles with a preprocessor or variables.

There are usually 3 ways of defining design tokens:

Tokens serve as the foundation to build component and design systems that can be reusable and consistent across applications.

Bundling

13. Scope hoisting or module concatenation

Reducing the number of files downloaded.

Web applications usually have dependencies and file imports. If those file imports are sent as-is to the browser, there would be a request waterfall, where each import would send a request to the server for each imported module. To avoid this, code is joined into the same file, using different strategies like:

The tradeoff to consider is that concatenating modules means every modification of a single file causes the bundled module to change, so it would need to be downloaded again and rerun in development. For example, it might be better not to concatenate a rendering framework like React to the main bundle, since it's large and changes to it are infrequent.

14. Dead code elimination (tree shaking)

Reducing downloaded code.

Applications might need utilities from other libraries, but not all. Dead code elimination deletes code that isn't used by the application.

The rules for code elimination vary through each bundler, but usually you should know that:

15. Code splitting

Splitting code into pieces and loading them when needed.

One could load the entire application as a single file, but that isn't efficient when the user only needs a small part of the web application. The idea of code splitting is creating multiple nodes, each one a hoisted module, of the flattened dependency tree. So instead of having a single file or the same source files, the output is a set of entry points that can be loaded on demand.

Frameworks usually include those strategies by default and integrate them with the organization of the application's code. If it needs to be added manually, bundlers usually understand dynamic imports with a static path, like:

// main.ts
const module = await import('./module-that-will-be-considered-as-an-entry-point.js')

Note that a lot of code splitting might cause waterfalls that the bundlers try to avoid with module hoisting in the first place. Ideally, code splitting should be per page (navigating) or per action (running an expensive operation), and not be more than 1 node deep considering the currently loaded code.

16. Static assets management

Only downloading changed assets.

One big benefit of bundling is that all assets, including images, fonts, CSS, and JavaScript, can have a unique hash in their path. With that, the browser can skip making a request to fetch the asset if it hasn't changed. This allows applications to launch faster on subsequent visits, since only the HTML (or any app entry point, like the service worker) needs to be downloaded.

With that, a cache for a large amount of time can be added without problems:

# every file in the _assets directory will have a static hash
/_assets/*
  Cache-Control: public, max-age=31536000, immutable

One problem with native ES modules is that since they rely on the path of the code to import it, if the path of a file changes, it will also change the content of the file that imports it, invalidating the cache of every file that imports it and its parents. Ideally, a solution could be integrated with a mapping of non-hashed names to hashed names, like what import maps do for only JavaScript modules.

Debugging

17. JS native debugging tools

Inspecting code execution without the hassle.

Just add the debugger statement for pausing the execution on an event:

debugger;

Note that the entire application will pause, so you can make modifications to parts of the application (that don't require code execution) or run code in the console at that break point.

For console logging, don't just use console.log. Try incorporating:

// Only logs when the condition is true
console.assert(condition, message)
// For tables and objects
console.table(data);
// trace to get where the function is being called without stopping the execution
console.trace();

18. Overwrites

Changing the code locally.

Most of the application can be modified locally. You can change the DOM or styles, overwrite headers of requests or the response contents, change device information like viewport, user agent, geolocation, preferred color theme, and more. You can modify application storage like localStorage, cookies, and IndexedDB.

Here it doesn't make sense to go in detail about how it works, since it would be like starting a tutorial, but I suggest that you explore how you can debug an application without the need to change the source code directly.

With the integration of workspaces and bundler plugins to include necessary metadata (for example, with this vite plugin), part of the changes can be saved directly back to the source code.

19. Accessibility

Check what you probably can't see.

The base tools that Chrome provides are the accessibility audit of Lighthouse, to see general issues of the web page, and the accessibility elements sub-tab, which allows you to see (or enable) the accessibility tree, and check the accessibility properties of each element. You can also emulate vision deficiencies on the rendering tab.

This is the topic where I know the least of this list, but I suggest using behavioral elements and libraries that provide accessibility out of the box, and shout at you if you forgot to define them in an accessible way. Keep it simple.

20. Chrome performance panel

The place to check performance.

The performance panel of Chrome has most of the information you need to understand the timeline of your application. With a recording, you can see a timeline with information on:

You can add annotations to the profile and save it to use later or to share it with others.

See their documentation for the full reference, and use it to understand the performance of your applications.

Preparing code

21. Package managers

How dependencies are installed

The base package manager is npm, which comes with Node. yarn started as a faster alternative to npm, but now npm has caught up. pnpm was created to solve the problem of node_modules disk space, and now has expanded to include more interesting features. Other honorable mentions are bun and deno, both tailored to work with their own ecosystems. Some other features besides package installation are:

Note that I'm talking about the package manager and not the registry. Registries can be the npm registry, yarn registry, GitHub packages, JSR, or any other that supports the npm package format.

Also, some solutions might manage the runtime version, like pnpm.

22. Linting

Analyzing code before running it.

Static analysis of code has many use cases, like formatting with prettier, checking for code quality with eslint, or checking for type errors with typescript. Formatters usually don't provide a lot of customization options, type checkers have limited plugin support, and code quality tools usually have a lot of plugins to check for specific code patterns.

Some integrations exist, for example, eslint-plugin-prettier with eslint-config-prettier and typescript-eslint, but usually with a big overhead compared to running those tools separately.

One problem is that these tools are configured separately and can clash with each other. There are attempts to unify this toolchain, like biome, oxc, and deno. One big challenge is reducing scope, for example, working on non-JS files or type-aware rules. Biome has the problem that it implements its own toolchain from scratch, so it can misalign with the native tools, and oxc isn't as mature, but is experimenting with native integrations like tsgo.

23. Testing

Ensuring test coverage.

The most common testing library used is jest. vitest is a newer alternative that incorporates similar APIs, but has a modern architecture that is integrated natively with Vite. Both provide:

Vitest provides additional tools like running only changed tests in developer mode and testing types. Jest also has great underlying strategies, like smarter scheduling which runs first the test files that fail most often, and then the ones that take a long time.

So when talking about unit testing, vitest is the best option when using Vite, and jest is a great option for other cases that don't require Vite. Runtimes have recently started including their own lightweight testing libraries, like node, bun, and deno.

Another strategy in frontend testing is end-to-end testing or component tests.

24. Mono-repo managers

Sharing packages with ease.

Even when package managers allow sharing packages without manual versioning via the workspace feature, managing a monorepo (a repository with multiple packages) can be painful. The main problem is speeding up tasks, mainly through task caching. Running all tasks like builds, linting, and testing can be sped up considerably by caching the results of previous runs, and with a task scheduler that understands the dependencies between tasks.

Some monorepo managers for JS are nx, turbo, and rush. They vary a lot in complexity: nx is complex with a plugin architecture that provides generators and additional tools, turbo is simple because it focuses mainly on caching, and monorepo tools like rush integrate enterprise features like package policies, committer email validation, and dependency approval.

The biggest impact of these tools can be obtained when they're integrated with the rest of the toolchain around publishing changes to the code, mainly for remote caching with a CI integration (usually requires an external service) and merge flow (only running tasks that the PR updates). Add these tools only when the integrated ones of the package manager aren't enough.

Other goodies

25. Hot module replacement

Fast development iteration.

HMR updates only the parts of the code that have changed in a running application. This works when the changed part doesn't have any side effects, so it can be added or removed safely. Common code that supports HMR includes components, styles, and real-time functions without side effects.

Since the runtime needs to update specific code, and each library might have its own way of mounting and unmounting code, HMR is handled by plugins in the bundler that add special code to each file that is HMR-compatible. For example, Vite and Webpack.

Note that HMR plugins have rules. For the React plugin for Vite, you can see the refresh rules here. In summary, for HMR to work correctly, each module should ideally only export components, and components shouldn't be defined inside functions or objects.

26. Complex primitives

Thinking with more than just blocks.

While the UI is mostly composed of boxes and elements that can be easily composed through the box component model, there are complex scenarios where that doesn't work as nicely or has complex interactions. For example:

The main consideration here is that, in a company, there should ideally be guidelines and a default theming or configuration for these components to provide the same consistency across applications as the rest of the UI.

27. Animations

Adding motion and dynamism to the UI.

Animations are another aspect that can be documented and standardized in a company or application, but with a big difference: the browser has multiple built-in solutions, and extending those solutions in a composable way usually requires a library.

The best way to start is to learn by example: see code that interests you and try to replicate it (you can use DevTools to see the implementation). For more advanced knowledge, I recommend checking out Emil Kowalski and his animation course, and Josh Comeau's blogs and courses.

28. Cleanup and maintenance

Keeping the codebase clean.

Over time, dependencies might be deprecated, simpler or faster alternatives might become available, bugs might be found, and code practices might change. While code is static, a software company or any active software project is not, and should prioritize maintenance of the underlying codebase.

One of the most interesting tools, outside of those provided by package managers to update dependencies, is Knip, a tool that can be used to clean unused code across the codebase with minimal configuration. The initiative e18e is an effort to simplify and modernize libraries, so check them out to learn about other tools or information on the topic.

Besides cleaning the list of all dependencies, there are also tools to help upgrade the codebase when migrating versions or libraries. These are usually written with jscodeshift, and some frameworks provide their own library of codemods to update versions, like Next.js and Storybook.

If you need to change your own codebase, you can use generative AI to build codemods, so you can have reproducible scripts that can be applied to large codebases without problems.


Items that didn't make the cut for this post

I might cover some of those in a detailed post in the future, but I had to leave it like that for my sanity and to match the 28/07 date :)