This document aims to serve as a baseline for the majority of medium to large-scale front-end / JavaScript-based projects in Arbisoft. We've curated the best practices in one place in order to ensure adherence to the quality level, performance, and extensibility of the products we deliver. Beyond all of the guidelines mentioned here, the most important thing to make an effort for is developing a culture of creating quality among ourselves; the rest will follow automatically.
Note: The recommendations of tools and libraries present in this document are suggested based on their long-standing past experiences and great track record in the industry for their performance and maintainability. Nonetheless, we know that the JS world is continuously evolving with innovation. Therefore if there is any library/tool superior to the suggested ones in this document, please discuss with the committee and we shall incorporate it in our guidelines.
Definitions
Choose a framework for a frontend that can build applications that are able to be used by thousands and millions of users. Keep performance, extensibility, and community support in mind when choosing a framework. Our recommendations of the framework(s) are:
- UI Library for the project should be implemented using storybooks
- Kick-start UI Libraries: rebass.js | blueprint | ant-design | material v4
- Folder Structure Which folder structure is ideal is a long-running discussion with varying subjective opinions. It is an important aspect to discuss nonetheless and the experience of working with large-scale applications tells that Component Folder Pattern[1][2] is the best one in terms of scaling for growing codebases.
- Forms A common pitfall is to implement custom form-handling because it is so easy to do it in React.JS - instead choose a mature & stable library like formik or react-final-form to handle all forms and their respective validations in a consistent, performant manner. (DO NOT useredux-form as its deprecated; react-final-form is the newest library from the same author)
- Date and Time Handling Usually the de-facto choice for handling date and time (w.r.t. to time-zones) in JS apps is moment.js, but it has a huge footprint on the bundle size of the app and is now in maintenance mode. Therefore it is recommended to use new light-weight alternatives like luxon or date-fns instead.
- Utility Functions Lodash is a great utility for working with reference data-types (objects, arrays, strings, nested structures) in order to manipulate or search them. However, it also has a large footprint on the bundle size of the app. Therefore it is advised to use individual lodash functions as packages. This way you can avoid installing the whole library.
- Immutability With the increasing demands of data-driven front-end apps, it is more than ever important to achieve immutability while working with JS reference types to achieve a predictable behavior of the language and avoid hard-to-debug errors. Therefore we recommend using immer.
- CSS in JSframeworks like styled-components
- JavaScript (Duh!) - ES2015+ aka ES6
- TypeScript
- JavaScript / TypeScript / JSX / Node.js: ESLint with thorough Linting Rules
- CSS / SCSS / CSS in JS: StyleLint
- Setup Linting as pre-commit Hook: Husky and Lint-Staged
- Use React.JS as V iew of MVC Although React.JS provides built-in APIs (State, Context) to work with the data, it is still better to treat React as a View technology, i.e., React part of the codebase should only be responsible for rendering dumb UI. This translates into React code mainly using Presentational (Functional) Components instead of Class Components and being free from any kind of data manipulation. Use selectors (explained later) to perform data manipulation and inject it as props to the respective Presentational Component and dispatch actions to perform any async operations. The biggest benefit of not using React's State for data manipulation is that one won't need to implement the lifecycle hook of React like shouldComponentUpdate to do performance because React will only be rendering dumb UI. [3][4]
- Prefer React Hooks over React Class Components For using React's Lifecycle hooks, use the new API of React Hooks instead of React's Class components.
- Create as many components as possible Even for the smallest UI elements
- Never use Indexes as Keys
- Define PropTypes of Components
- Lifting UI state in Global Store The pattern of using React.JS also advocates lifting the state of application's UI elements from React's state into a global state like Redux (with the only exception being Forms), such that there is a single state-tree for the whole application. This also strengthens the deterministic nature of the application. Reference
- Decompose Reducers Split up Reducers into maintainable, extensible chunks.[5]
- Managing Side Effects with redux-saga Sagas is hands-down the best option to manage async processes (aka side-effects) for large-scale applications because they can handle async-chaining of side-effects by completely avoiding callback hell (redux-thunks cannot do this without a messy, buggy code). Sagas encourage clean and reusable code with minimum boilerplate and encourage explicitly, yet consistent error-handling of async actions. Sagas provide a very clean way of accessing stores (sorry thunks) and are capable of handling parallel processes, forking, spawning, canceling, and a lot more. [6][7][8][9][10]
- Use Selectors (with Memoization) Most of the components will need some dynamic data to be injected as props. For that purpose, an anti-pattern is to subscribe to the store (with connect() binding) and manipulate the props inside the component. This is completely wrong and any local manipulation of the data should be performed in selector functions. One should also use selectors for getting static data into the components, instead of hard-coding the static data in components. [11][12][13]
- Data Normalization Implementing DRY in Redux means that one should also manage data duplication in the global store. This means that for large-scale apps, one should treat the application's global storage as a database. [14][15]
- Reduce Boilerplate with Ducks Ever tried scaffolding your types/actions/reducers code? Use Ducks Pattern to minimize the repetitive codebase and ensure naming consistency in the redux entities.
- Eliminate unused code with Tree-shaking Use ES6 Modules to import only required components in your code files, instead of importing default modules as a whole. When using destructured imports Webpack performs tree-shaking and eliminates dead-code in the production build. [16][17]
- Remove render-blocking JavaScript JavaScript blocks the normal parsing lifecycle of HTML documents, so when the parser reaches a <script> tag inside the <head>, it stops to fetch and run it. Adding async or defer is highly recommended if your scripts are placed at the <header> of the page. [18][19]
- Reduce JavaScript Payloads with Code Splitting A common anti-pattern is to load the whole bundle when a user lands on the website, even though the landing page doesn't need JS of all 30 other pages. In order to optimize the downloading times and bundle size of the application, use Code Splitting for your application.[20][21]
- Divide CSS per-component basis Component level CSS helps keep the main CSS bundle lightweight which can also help at the time of code-splitting.
- Maximize Reusability Using frameworks like styled-components, you can easily achieve maximum reusability of CSS by making as many generic styles as possible.
- Remove Render Blocking CSS CSS files need to be non-blocking to prevent the DOM from taking time to load.
- Eliminate unused CSS CSS files that need to be non-blocking to prevent the DOM from taking time to load. Make sure your bundler can handle this.
- Use HTML5 Elements with Correct Semantics [22]
- Have Error Pages Configured (for 400, 500 errors with Inline CSS)
- Minify HTML for Production
- Place CSS tags before JS To avoid render-blocking in CSS Having your CSS tags before any JavaScript enables better, a parallel download which speeds up browser rendering time. [23]
Modern web applications often use bundling tools like Webpack or Parcel. Whatever bundler you use, there are certain aspects to take care of.
- Decrease Bundle Size By minification of code, optimizing images, breaking to vendor vs. app bundles, lazy loading on routing level, etc.
- Use long-term Caching Effective use of caching can save client's time on re-fetching bundles, hence ensuring low bandwidth usage and greater speed of application.
- Guide To Webpack's Optimization [24]
- Page Load Time < 3 seconds | Page Weight < 500 KB (before gzipped)
- TTFB (Time To First Byte) [25] < 1.3 seconds | Time To Interact < 5 sec [26]
- Continuously Monitor app bundle for smaller size [27][28]
- Continuously Profile app's rendering performance [29][30]
- Secrets and App-level Constants as Configurations Hardcoding of secrets, 3rd-party URLs is a major security risk, therefore moving such constants out of the codebase to environment variables is highly recommended. This also lets you separate out secrets for multiple environments.[31][32]
- Containerize the Application "It works on my machine" syndrome should be handled early on in the development lifecycle to ensure reproducibility of the application's artifacts. This can save a lot of time later on at the time of deployment to test and production environments. Docker is the de-facto standard for achieving containerization.[33][34] For mono-repos, it is best to use docker-compose.
- Separate out build-configuration per Environment
- Separate out docker-builds per Environment
- Track Configurations (not Secrets) under app/confs (or separate org/configs repo)
- Integrate CI to continuously Lint and Test codebase against each PR in your Platform such as CircleCI, GitHub Actions, GitLab CI, etc. [35]
Testing is an essential part of the application lifecycle and it increases the maturity of the codebase with increasing feature-set. Jest provides the most advanced test-runner with a lot of built-in assertions. react-testing-library
This is a low-friction, low-return type of testing using which with a few lines of code, one can persist the Markup of UI components and track accidental changes.
You should write e2e integration tests that can fire up some actions, trigger sagas to execute processes of application, and then finally test the expected state of reducers. This is the most crucial part of testing and if the project uses redux-sagas, the testing experience is really intuitive and meaningful. This is another reason to ditch redux-thunks in favor of sagas.
With simple test assertions of Jest, one should write unit tests for all utility functions/helpers present in the codebase to ensure correct behavior.
- Using Jest's built-in code-coverage reports, keep an eye on the code coverage and always aim for higher code coverage. Use tools like codecov to integrate into your development workflow and test code coverage against each PR in your CI pipeline.
- Automate the Acceptance / Black Box Testing of the application with Cypress.io and integrate it in your CI pipeline to ensure that the new feature-set does not break the application.
- Using tools like codeclimate you can set up engineering analytics and help Engineering Managers understand the productivity of developers and detect bottlenecks in the delivery of features by extracting information from code commits and pull requests.