This document describes frontend architecture for a SaaS system to manage tasks. It covers infrastructure, tech stack, viable approaches to fulfill certain requirements and desirable API.
Vite bundler (which is basically an industry standard nowadays) is going to be used for bundling, code splitting and live reloading during development. Another two industry standards - ESLint and Prettier - are going to be used for linting and formatting correspondingly. We can also setup pre-commit hooks via Husky and lint-staged libraries to ensure that no linting, formatting or type errors slip through. The app will be written in TypeScript, which helps to reduce the number of runtime errors and also makes refactoring easier.
For this particular application, it makes sense to use React as a main framework. Not only because of its popularity and vast ecosystem, but because of its concurrent rendering capabilities and also upcoming offscreen rendering feature. As we might need to render a huge amount of data (we might not, but this is something to be discussed), we don't want to block user input (for example while filtering), when this data is being rendered or updated.
If Tasks, Photos and Documents are separate applications, that use the same design system, it makes sense to put all the code (for apps, design system and maybe some common utils) in a monorepo. It will help to keep things consistent and in sync. And also we'll be able to share TypeScript, ESLint and Prettier configurations between packages. The most robust tool for monorepos nowadays is Turborepo, which heavily uses caching to instantly rebuild packages, no matter how huge a repository is.
There are two ways to organize code in the app: by type and by feature. While "by feature" approach looks attractive, my experience shows that it doesn't really work, because features often intersect, and you still need to extract common parts somewhere. I advocate for a hybrid approach: code is organized by type (components, pages, forms, charts and so on), but some building blocks are colocated. For example, some page component folder might also contain components (or types, or data fetching hooks, or stores) specific for this page.
If it's possible to write Tasks API using NodeJS, leveraging the existing services and databases, then I'd propose to use tRPC, a type-safe alternative to traditional REST, which allows sharing API types between frontend and backend. In this case, API package will also be in the monorepo. If it's not possible, then I'll just describe new endpoints later in this document.
App state will be kept in four places: component's local state, global app state, server cache and URL. Global app state will be used for inter-component communication (if needed), server cache will contain all the fetched data and URL parameters will be used to persist certain app states (like current filter values, opened modal dialogs etc.) and share them between users or restore them in different browser windows.
To ensure our application works well on all devices, we're going to use mobile-first approach, meaning that all the components and screens will be implemented for mobile displays first and then extended to other resolutions. We can also set up a testing pipeline which will create app screenshots for different resolutions on every commit. A hardcore approach would be to use something like AWS Device Farm to run our full-fledged e2e tests.
For this feature, we're going to use CRDT technology. Later in this document, I'll propose some of the existing solutions.
I assume that SEO is not that important thing for the Tasks app, so there is no need for server rendering (which complicates the architecture) and we can just go with full SPA mode. We'll just have to be careful not to make user to download an extremely huge JavaScript bundle. The easiest and most straightforward solution to avoid that - "by page" code-splitting.
- Language - TypeScript
- Bundler - Vite
- UI framework - React
- Routing - React Router (once again an industry standard). Another approach worth investigating is to use Remix framework (from the React Router creators), when they release SPA-only mode. It does a lot of heavylifting for you and comes with many performance optimizations out of the box.
- Data fetching - React Query. Simply the best data fetching library, which makes it extremely easy to implement such common patterns as "infinite scrolling", "optimistic updates", "data persistence" and so on. By keeping all the data in React Query's cache, we'll be able to easily and instantly switch between different views for the same data without refetching.
- Form management - React Hook Form. The most flexible and performant form management library.
- Global state management - Jotai. Atom-based state management library with a small API surface. Single store solutions, like Redux, don't scale well, while atoms can be collocated with corresponding components and rerenders are automatically optimized.
- Styling - CSS Modules + PostCSS. I think it's a perfect solution for styling. You write normal CSS and your styles are isolated. Other solutions like CSS-in-JS add too much overhead, come with learning curve and have performance implications.
- Charts and graphs - Victory or VisX. Just two mature charting libraries from big players. Either can be picked.
- CRDT - YJS, Tinybase, Liveblocks, PartyKit. We're going to need to compare these libraries and choose the most suitable solution for us.
- Testing - React Testing Library (for unit tests) and Playwright (for e2e tests)
- API - tRPC (if possible)
- Repo management - Turborepo
- Linting - ESLint
- Formatting - Prettier
For all the task-related endpoints, I assume that every task has unique id
-
Endpoint to get all tasks. As we need to have complex filtering capabilities, this endpoint is a POST one. With GET endpoint, we might exceed the maximum length of the URL.
{ filter: { query?: String // to search by title or full-text search companyId?: String tags?: String[] assignee?: String .... }, skip: Number // Depending on the use case we might want to have cursor based pagination limit: Number sortBy: String // field name sortDir: ASC | DESC }
{ results: Object[] // array of tasks total: Number // total number of results fitting search criteria }
-
Endpoint to get specific task
Requested task
-
Endpoint for task creation.
{ title: String type: String // type id tags: String[] // list of tags ids assignee: String // user id description: String companyId: String ... }
Newly created task
-
Endpoint for task editing.
{ title?: String type?: String // type id tags?: String[] // list of tags ids assignee?: String // user id description?: String ... }
Edited task
-
Endpoint for task deletion.
{ id: String // id of the removed task }
-
We're gonna need also the following endpoints in our application:
- GET /companies
- GET /companies/:companyId
- GET /users
- GET /users/:userId
- GET /tags
- GET /types
Honestly I'm struggling to come up with any limitations except for inability to render all million tasks at once :)