Skip to main content

Tutorial

Let's consider the application of Feature-Sliced Design on the example of TodoApp

  • At first, we will prepare application basely (bootstrap, routing, styles)
  • Then we will consider - how the concepts of the methodology help flexibly and effectively design business logic without unnecessary costs

There is codesandbox-insert with the final solution, which can help to clarify the implementation details at the end of the article

Stack: React, Effector, TypeScript, Sass, AntDesign

note

The tutorial is designed to reveal the practical idea of the methodology itself. Therefore, the practices described here are largely suitable for other technological stacks of frontend projects

1. Preparationโ€‹

1.1 Initializing the projectโ€‹

At the moment, there are many ways to generate and run a project template

We will not focus too much on this step, but for quick initialization, you can use CRA (for React):

$ npx create-react-app todo-app --template typescript

1.2 Preparing the structureโ€‹

We received the following blank for the project

โ””โ”€โ”€ src/
โ”œโ”€โ”€ App.css
โ”œโ”€โ”€ App.test.tsx
โ”œโ”€โ”€ App.tsx
โ”œโ”€โ”€ index.css
โ”œโ”€โ”€ index.ts
โ”œโ”€โ”€ logo.svg
โ”œโ”€โ”€ react-app-env.d.ts
โ”œโ”€โ”€ reportWebVitals.ts
โ”œโ”€โ”€ setupTests.ts
โ””โ”€โ”€ index.tsx/

How it usually happensโ€‹

And usually most projects at this stage turn into something like this:

โ””โ”€โ”€ src/
โ”œโ”€โ”€ api/
โ”œโ”€โ”€ components/
โ”œโ”€โ”€ containers/
โ”œโ”€โ”€ helpers/
โ”œโ”€โ”€ pages/
โ”œโ”€โ”€ routes/
โ”œโ”€โ”€ store/
โ”œโ”€โ”€ App.tsx
โ””โ”€โ”€ index.tsx/

They can become such immediately, or after a long development

At the same time, if we look inside we will most likely find:

  • Highly coupled directories by nesting
  • Strongly connected components with each other
  • A huge number of dissimilar components / containers in their respective folders, linked thoughtlessly

How can it be done otherwiseโ€‹

Anyone who has been developing frontend projects for at least a long time understands the advantages and disadvantages of this approach.

However, most frontend projects are still something like this, since there is no proven flexible and extensible alternative

Multiply this by the free adaptations of the structure for each project, without a ban from the framework-and we get "projects as unique as snowflakes"

The purpose of this tutorial is to show a different view of the usual practices in designing

Adapting the structure to the desired viewโ€‹

โ””โ”€โ”€ src/
โ”œโ”€โ”€ app/ # Initializing application logic
| โ”œโ”€โ”€ index.tsx # Entrypoint for connecting the application (formerly App. tsx)
| โ””โ”€โ”€ index.css # Global application styles
โ”œโ”€โ”€ pages/ #
โ”œโ”€โ”€ widgets/ #
โ”œโ”€โ”€ features/ #
โ”œโ”€โ”€ entities/ #
โ”œโ”€โ”€ shared/ #
โ””โ”€โ”€ index.tsx # Connecting and rendering the application

At first glance the structure may seem strange, but over time you will notice that you use familiar abstractions, but in a consistent and ordered form.

Also, we enable support for absolute imports for convenience

tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
// Or aliases, if it's more convenient

Here's how it will help us in the future

- import App from "../app"
- import Button from "../../shared/ui/button";
+ import App from "app"
+ import Button from "shared/ui/button";

Layers: appโ€‹

As you can see , we have moved all the basic logic to the app/ directory

It is there, according to the methodology, that all the preparatory logic should be placed:

  • connecting global styles (/app/styles/** + /app/index.css)
  • providers and HOCs with initializing logic (/app/providers/**)

For now, we will transfer all the existing logic there, and leave the other directories empty, as in the diagram above.

app/index.tsx
import "./index.css";

const App = () => {...}

1.3 Enabling global stylesโ€‹

Install dependenciesโ€‹

In the tutorial, we install sass, but you can also take any other preprocessor that supports imports

$ npm i sass

Creating files for stylesโ€‹

For css variablesโ€‹
app/styles/vars.scss
:root {
--color-dark: #242424;
--color-primary: #108ee9;
...
}
To normalize stylesโ€‹
app/styles/normalize.scss
html {
scroll-behavior: smooth;
}
...
Connecting all stylesโ€‹
app/styles/index.scss
@import "./normalize.scss";
@import "./vars.scss";
...
app/index.scss
@import "./styles/index.scss";
...
app/index.tsx
import "./index.scss"

const App = () => {...}

1.4 Adding routingโ€‹

Install dependenciesโ€‹

$ npm i react-router react-router-dom compose-function
$ npm i -D @types/react-router @types/react-router-dom @types/compose-function

Add HOC to initialize the routerโ€‹

app/providers/with-router.tsx
import { Suspense } from "react";
import { BrowserRouter } from "react-router-dom";

export const withRouter = (component: () => React.ReactNode) => () => (
<BrowserRouter>
<Suspense fallback="Loading...">
{component()}
</Suspense>
</BrowserRouter>
);
app/providers/index.ts
import compose from "compose-function";
import { withRouter } from "./with-router";

export const withProviders = compose(withRouter);
app/index.tsx
import { withProviders } from "./providers";
...

const App = () => {...}

export default withProviders(App);

Let's add real pagesโ€‹

note

This is just one of the routing implementations

  • You can declare it declaratively or through the list of routes (+ react-router-config)
  • You can declare it at the pages or app level

The methodology does not yet regulate the implementation of this logic in any way

Temporary page, only for checking the routingโ€‹

You can delete it later

pages/test/index.tsx
const TestPage = () => {
return <div>Test Page</div>;
};

export default TestPage;
Let's form the routesโ€‹
pages/index.tsx
// Or use @loadable/component, as part of the tutorial - uncritically
import { lazy } from "react";
import { Route, Switch, Redirect } from "react-router-dom";

const TestPage = lazy(() => import("./test"));

export const Routing = () => {
return (
<Switch>
<Route exact path="/" component={TestPage} />
<Redirect to="/" />
</Switch>
);
};
Connecting the routing to the applicationโ€‹
app/index.tsx
import { Routing } from "pages";

const App = () => (
// Potentially you can insert here
// A single header for the entire application
// Or do it on separate pages
<Routing />
)
...

Layers: app, pagesโ€‹

Here we used several layers at once:

  • app - to initialize the router (HOC: withRouter)
  • pages - for storing page modules

1.5 Let's connect UIKitโ€‹

To simplify the tutorial, we will use the ready-made UIKit from AntDesign

$ npm i antd @ant-design/icons
app/styles/index.scss
@import 'antd/dist/antd.css';
tip

But you can use any other UIKit or create your own by placing the components in shared/ui - this is where it is recommended to place UIKit of application:

import { Checkbox } from "antd"; // ~ "shared/ui/checkbox"
import { Card } from "antd"; // ~ "shared/ui/card"

2. Implementing business logicโ€‹

note

We will try to focus not on the implementation of each module, but on their sequential composition

2.1 Let's analyze the functionalityโ€‹

Before starting the code, we need to decide - what value we want to convey to the end user

To do this, we decompose our functionality by responsibility scopes (layers)

layers-flow-themed

Note: the diagram shows an experimental layer of "Widgets", which is unnecessary in the framework of the tutorial and the specification of which will be added soon

Pagesโ€‹

We will outline the basic necessary pages, and user expectations from them:

  1. TasksListPage - the "Task List" page

    • View the task list
    • Go to the page of a specific task
    • Mark a specific task completed/unfulfilled
    • Set filtering by completed / unfulfilled tasks
  2. TaskDetailsPage - page "Task card"

    • View information about the task
    • Mark a specific task as completed/unfulfilled
    • Go back to the task list

Each of the described features is a part of the functionality

Usual approachโ€‹

And there is a great temptation

  • or implement all the logic in the directory of each specific page.
  • or put all" possibly reused "modules in the shared folder src/components or similar

But if such a solution would be suitable for a small and short-lived project, then in real corporate development, it can put an end to the further development of the project, turning it into "another dense legacy"

This is due to the usual conditions of the project development:

  • requirements change quite often
  • there are new circumstances
  • the technical debt is accumulating every day and it is becoming more difficult to add new features
  • it is necessary to scale both the project itself and its team
Alternative approachโ€‹

Even with the basic partitioning, we see that:

  • there are common entities between the pages and their display (Task)
  • there are common features between the pages (Mark the task completed / unfulfilled)

Accordingly, it seems logical to continue to decompose the task, but already based on the above-mentioned features for the user.

Featuresโ€‹

Parts of functionality that bring value to the user

  • <ToggleTask /> - (component) Mark a task as completed / unfulfilled
  • <TasksFilters/> - (component) Set filtering for the task list

Entitiesโ€‹

Business entities on which a higher-level logic will be built

  • <TaskCard /> - (component) Task card, with information display
  • getTasksListFx({ filters }) - (effect) Loading the task list with parameters
  • getTaskByIdFx(taskId: number)- (effect) Uploading a task by ID

Sharedโ€‹

Reused shared modules, without binding to the domain scopes

  • <Card /> - (component) UIKit component
    • At the same time, you can either implement your own UIKit for the project, or use a ready-made one
  • getTasksList({ filters }) - (api) Loading the task list with parameters
  • getTaskById(taskId: number) - (api) Loading a task by ID

What is the profit?โ€‹

Now all modules can be designed with low coupling and with their own scope of responsibility, as well as distributed across the team without conflicts during development

And most importantly, now each module serves to build a specific business value, which reduces the risks for creating "features for the sake of features"

2.2 What else is worth rememberingโ€‹

Layers and responsibilitiesโ€‹

As described above, thanks to the layered structure, we can predictably distribute the complexity of the application according to scopes of responsibility, i.e. layers.

At the same time, a higher-level logic is built on the basis of the underlying layers:

// (shared)         => (entities)  + (features)     => (pages)
<Card> + <Checkbox> => <TaskCard/> + <ToggleTask/> => <TaskPage/>

Preparing modules for useโ€‹

Each implemented module must provide its own public interface for use:

{layer}/foo/index.ts
export { FooCard, FooThumbnail, ... } from "./ui";
export * as fooModel from "./model";
info

If you need named namespace exports for the Public API declaration, you can look aside @babel/plugin-proposal-export-namespace-from

Or, as an alternative, use a more detailed design

{layer}/foo/index.ts
import { FooCard, FooThumbnail, ... } from "./ui";
import * as fooModel from "./model";

export { FooCard, FooThumbnail, fooModel };

2.3 Let's display the basic task listโ€‹

(entities) Task cardโ€‹

entities/task/ui/task-row/index.tsx
import { Link } from "react-router-dom";
import cn from "classnames"; // we can safely use the analogy
import { Row } from "antd"; // ~ "shared/ui/row"

export const TaskRow = ({ data, titleHref }: TaskRowProps) => {
return (
<Row className={cn(styles.root, { [styles.completed]: data.completed })}>
{titleHref ? <Link to={titleHref}>{data.title}</Link> : data.title}
</Row>
)
}

(entities) Loading the task listโ€‹

You can split it by the type of entity, or store everything in the duck-modular style

For more information about the implementation of the API according to the tutorial, see here

entities/task/model/index.ts
import { createStore, combine, createEffect, createEvent } from "effector";
import { useStore } from "effector-react";

import { typicodeApi } from "shared/api";
import type { Task } from "shared/api";

// Each effect can also have its own additional. processing
const getTasksListFx = createEffect((params?: typicodeApi.tasks.GetTasksListParams) => {
// There may also be an additional processing the effect
return typicodeApi.tasks.getTasksList(params);
});

// Can also be stored in a normalized form
export const $tasks = createStore<Task[]>([])
.on(getTasksListFx.doneData, (_, payload) => ...)

export const $tasksList = combine($tasks, (tasks) => Object.values(tasks));
// You can also add other things like `isEmpty`, `isLoading`, ...

(pages) Let's connect all the logic on the pageโ€‹

pages/tasks-list/index.tsx
import { useEffect } from "react";
// If you feel confident with @effector/reflect - can use it
// Within the tutorial non-critical
import { useStore } from "effector";
import { Layout, Row, Col, Typography, Spin, Empty } from "antd"; // ~ "shared/ui/{...}"

import { TaskRow, taskModel } from "entities/task";
import styles from "./styles.module.scss";

const TasksListPage = () => {
const tasks = useStore(taskModel.$tasksList);
const isLoading = useStore(taskModel.$tasksListLoading);
const isEmpty = useStore(taskModel.$tasksListEmpty);

/**
* Requesting data when loading the page
* @remark is a bad practice in the effector world and is presented here-just for a visual demonstration
* It is better to fetch via event.pageMounted or reflect
*/
useEffect(() => taskModel.getTasksListFx(), []);

return (
<Layout className={styles.root}>
<Layout.Toolbar className={styles.toolbar}>
<Row justify="center">
<Typography.Title level={1}>Tasks List</Typography.Title>
</Row>
{/* TODO: TasksFilters */}
</Layout.Toolbar>
<Layout.Content className={styles.content}>
<Row gutter={[0, 20]} justify="center">
{isLoading && <Spin size="large" />}
{!isLoading && tasks.map((task) => (
<Col key={task.id} span={24}>
<TaskRow
data={task}
titleHref={`/${task.id}`}
// TODO: ToggleTaskCheckbox
/>
</Col>
))}
{!isLoading && isEmpty && <Empty description="No tasks found" />}
</Row>
</Layout.Content>
</Layout>
);
};

2.4 Adding task status switchingโ€‹

(entities) Switching the task statusโ€‹

entities/task/model/index.ts
export const toggleTask = createEvent<number>();

export const $tasks = createStore<Task[]>(...)
...
.on(toggleTask, (state, taskId) => produce(state, draft => {
const task = draft[taskId];
task.completed = !task.completed;
console.log(1, { taskId, state, draft: draft[taskId].completed });
}))


// We make a hook to get involved in updates react
// @see In the case of effector, using a hook is an extreme measure, since computed stores are more preferable
export const useTask = (taskId: number): import("shared/api").Task | undefined => {
return useStoreMap({
store: $tasks,
keys: [taskId],
fn: (tasks, [id]) => tasks[id] ?? null
});
};

(features) Checkbox for the taskโ€‹

features/toggle-task/ui.tsx
import { Checkbox } from "antd"; // ~ "shared/ui/checkbox"
import { taskModel } from "entities/task";

// resolve / unresolve
export const ToggleTask = ({ taskId }: ToggleTaskProps) => {
const task = taskModel.useTask(taskId);
if (!task) return null;

return (
<Checkbox
onClick={() => taskModel.toggleTask(taskId)}
checked={task.completed}
/>
)
}

(pages) Embedding the checkbox in the pageโ€‹

What is noteworthy is that the task card does not know at all about the page where it is used, nor about what action buttons can be inserted into it (the same can be said about the feature itself)

This approach allows you to simultaneously competently share responsibility and flexibly reuse logic during implementation

pages/tasks-list/index.tsx
import { ToggleTask } from "features/toggle-task";
import { TaskRow, taskModel } from "entities/task";
...
<Col key={task.id} span={24}>
<TaskRow
...
before={<ToggleTask taskId={task.id} withStatus={false} />}
/>
</Col>

2.5 Adding task filteringโ€‹

(entities) Filtering at the data levelโ€‹

entities/task/model/index.ts
export type QueryConfig = { completed?: boolean };

const setQueryConfig = createEvent<QueryConfig>();

// Can be moved to a separate directory (for storing multiple models)
export const $queryConfig = createStore<QueryConfig>({})
.on(setQueryConfig, (_, payload) => payload)

/**
* Filtered Tasks
* @remark Can be handled at the effects level - but then you need to connect additional logic to the store
* > For example, hide / show the task at the `toggleTask` event
*/
export const $tasksFiltered = combine(
$tasksList,
$queryConfig,
(tasksList, config) => {
return tasksList.filter(task => (
config.completed === undefined ||
task.completed === config.completed
))},
);

(features) UI controls for filtersโ€‹

features/tasks-filters/ui.tsx
// If you feel confident with @effector/reflect, you can immediately use it
// As part of tutorial uncritically
import { useStore } from "effector";
import { Radio } from "antd"; // ~ "shared/ui/radio"

import { taskModel } from "entities/task";
import { filtersList, getFilterById, DEFAULT_FILTER } from "./config";

export const const TasksFilters = () => {
const isLoading = useStore($tasksListLoading);

return (
<Radio.Group defaultValue={DEFAULT_FILTER} buttonStyle="solid">
{filtersList.map(({ title, id }) => (
<Radio.Button
key={id}
onClick={() => taskModel.setQueryConfig(getFilterById(id).config)}
value={id}
disabled={isLoading}
>
{title}
</Radio.Button>
))}
</Radio.Group>
);
};

(pages) Implementing filtering in the pageโ€‹

And we implemented the logic again, without asking too many questions:

  • And where to put the filtering logic?
  • Can these filters be reused in the future?
  • Can filters know about the page context?

We just divided the logic according to the scopes of responsibility (layers)

pages/tasks-list/index.tsx
import { TasksFilters } from "features/tasks-filters";
...
<Layout.Toolbar className={styles.toolbar}>
...
<Row justify="center">
<TasksFilters />
</Row>
</Layout.Toolbar>
note

At the current stage, such a division may seem superfluous - "Why not put everything at once at the page / feature level"?

But then let's try to ask questions ourselves:

  • Where are the guarantees that the complexity of the page will not increase in the future so much that all aspects of logic will be strongly intertwined? How can I add new functionality at no extra cost?
  • Where are the guarantees that a new person who has joined the team (or even you, if you leave the project for six months) will understand what is happening here?
  • How to build logic so as not to disrupt the data flow / reactivity with other features?
  • What if this filtering logic is so strongly attached to the context of the page that it will be impossible to use it on other pages?

This is why we divide the responsibility so that each layer is engaged in only one task, and so that each of the developers understands this

2.6 Task Pageโ€‹

We implement the task page in the same way:

  • We highlight the shared logic
  • We highlight the entities logic
  • We highlight the features logic
  • We highlight the pages logic

(pages) The"Task Card" pageโ€‹

pages/task-details/index.tsx
import { ToggleTask } from "features/toggle-task";
import { TaskCard, taskModel } from "entities/task";
import { Layout, Button } from "antd"; // ~ "shared/ui/{...}"
import styles from "./styles.module.scss";

const TaskDetailsPage = (props: Props) => {
const taskId = Number(props.match?.params.taskId);
const task = taskModel.useTask(taskId);
const isLoading = useStore(taskModel.$taskDetailsLoading);

/**
* Requesting data on the task
* @remark is a bad practice in the effector world and is presented here-just for a visual demonstration
* It is better to fetch via event.pageMounted or reflect
*/
useEffect(() => taskModel.getTaskByIdFx({ taskId }), [taskId]);

// You can transfer part of the logic to entity/task/card (as a container)
if (!task && !isLoading) {
return ...
}

return (
<Layout className={styles.root}>
<Layout.Content className={styles.content}>
<TaskCard
data={task}
size="default"
loading={isLoading}
className={styles.card}
bodyStyle={{ height: 400 }}
extra={<Link to="/">Back to TasksList</Link>}
actions={[
<ToggleTask key="toggle" taskId={taskId} />
]}
/>
</Layout.Content>
</Layout>
)
};

2.7 What's next?โ€‹

And then new tasks arrive, new requirements are identified

At the same time, the old code base does not require significant rework

Has the functionality tied to the user appeared?โ€‹

=> Adding entities/user

Did you need to change the filtering logic?โ€‹

=> Changing the processing at the entities or pages level, depending on the scale

Do you need to add more features to the task card, but at the same time, so that it can be used in the old way?โ€‹

=> Add features and insert them into the card only on the desired page

Has a module become too complex to support?โ€‹

=> Thanks to the embedded architecture, we can only factor this module in isolation-without implicit side effects for others (and even rewrite it from scratch)

Summaryโ€‹

We have learned how to apply the methodology for basic casesโ€‹

Obviously, the world is much more complicated, but now we have already caught on to some controversial points and resolved them in such a way that the project remains supported and extensible.

We got a scalable and flexible codebaseโ€‹

  1. Reused and expandable modules

    • shared, features, entities
  2. Uniform and predictable distribution of logic

    • Since the composition goes in the same direction (the overlying layers use the underlying ones) , we can predictably track and modify it without fear of unforeseen consequences
  3. The structure of the application, which tells about the business logic for itself

    • What pages are there?
      • TasksList, TaskDetails
    • What features are there? What can the user do?
      • ToggleTask TasksFilters
    • What are the business entities? What is the work being done with?
      • Task (TaskCard, ...)
    • What can be reused from the auxiliary?
      • UIKit (Card, ...) API (tasksApi)

Exampleโ€‹

Below in Codesandbox is an example of the resulting TodoApp, where you can study in detail the final structure of the application

See alsoโ€‹