├── .gitignore ├── README.md ├── docs └── img │ ├── diagram.png │ ├── layered_architecture.png │ └── simple_counter.png ├── package.json ├── public └── index.html ├── src ├── app │ ├── counter │ │ ├── controller │ │ │ └── counterViewModel.ts │ │ ├── data │ │ │ ├── counterActionTypes.ts │ │ │ ├── counterActions.ts │ │ │ ├── counterReducer.ts │ │ │ ├── counterService.ts │ │ │ └── counterStoreImplementation.ts │ │ ├── domain │ │ │ ├── counterEntity.ts │ │ │ ├── counterModel.ts │ │ │ └── counterStore.ts │ │ ├── useCases │ │ │ ├── decrementCounterUseCase.ts │ │ │ ├── getCounterUseCase.ts │ │ │ ├── incrementCounterUseCase.ts │ │ │ └── updateCounterUseCase.ts │ │ └── view │ │ │ └── CounterView.tsx │ ├── main │ │ ├── data │ │ │ └── appStoreImplementation.ts │ │ └── view │ │ │ └── AppView.tsx │ └── shared │ │ └── ui │ │ ├── Button.ts │ │ └── Spinner.tsx ├── index.tsx ├── react-app-env.d.ts └── styles.css ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Find a version in spanish of this article on my website: [ 2 | Arquitectura CLEAN para el frontend](https://daslaf.dev/posts/arquitectura-clean-react) 3 | 4 | # On layered architecture 🍰 5 | 6 | Layering is not a novel concept. It's been around in the industry for more than a couple of years (some of you reading this document are probably younger than layering) and it's one of the first architectural styles created. In short, layering is nothing more than dividing the concerns of your application into different layers, like in a cake, where the upper layers can talk to the bottom layers but no the other way around. 7 | 8 | Layers interact through facades, so as long as the public APIs are honored, a layer doesn't have to know anything about internal implementation details of other layers. 9 | 10 | Let's take a look at the following diagram: 11 | 12 | ![layered_architecture.png](docs/img/layered_architecture.png) 13 | 14 | Layered architecture, a diagram. 15 | 16 | The most typical layered architecture has three layers: *UI*, *Domain* and *Infrastructure*. Our systems can have as many layers as needed, it doesn't have to be just 3. It's just that this is the most typical one. 17 | 18 | Translating this into a React application, what we would do is have our view components in the top layer. Then our state management solution would go in the layer below. Last but not least we would have an infrastructure layer for talking to external resources, like our backend, a firebase database, pusher, local storage, and any other external source of information. 19 | 20 | For a small application this is good enough, and it's probably how we've been writing React applications for a long time. But as applications grow, these layers keep getting fatter and they start doing too much, which makes them harder to reason about. 21 | 22 | Before jumping into that mumbo jumbo, let's talk real quick about the benefits of layering and why we want to explore implementing a layered architecture. 23 | 24 | ### Ease of reasoning 25 | 26 | Divide and conquer: the best way of solving a big problem is splitting it into smaller problems that are easier to solve. We can reason about a layer independently without worrying about the implementation of other layers. 27 | 28 | ### Substitution 29 | 30 | Layers can be easily substituted with alternative implementations. It's not like we're switching our http library everyday, but when the time comes, the change is self contained within a layer and it should never leak outside the layer's boundaries. Refactoring becomes easier and less intrusive. 31 | 32 | ### Evolution 33 | 34 | Architectures that scale must have the capacity of evolving as software matures and requirements change. Although we like to do some design upfront, there are things that will only show up after development starts. When using layers, we can delay decisions about implementation details until we have enough information to make a sensible choice. 35 | 36 | ### Decoupling 37 | 38 | Dependencies between layers are controlled since they're one directional. Aiming for low coupling (while maintaining high cohesion, or [colocation](https://kentcdodds.com/blog/colocation)) is a nice way to avoid our application becoming a big ball of mud. 39 | 40 | ### Testability 41 | 42 | Having a layered architecture allows testing each component in isolation easy. Although this is nice, in my opinion it is not the greatest benefit in terms of testability. For me the greatest benefit of layered architectures is that it’s easier to write tests while working on the code. Since each layer should have a well defined responsibility, it's easier to think about what's worth testing during implementation. 43 | 44 | All of the things mentioned above help us to write code that's easier to maintain. A maintainable codebase makes us more productive as we spend less time fighting against technical debt and more time working on new features. It also reduces risk when introducing changes. Last but not least, it makes our code easier to test, which ultimately gives us more confidence during development and refactoring. 45 | 46 | Now that we know the benefits of layering and layered architectures, let us talk about what type of layered architecture we are proposing for a large React app. 47 | 48 | # CLEAN architecture 49 | 50 | CLEAN architecture is a type of layered architecture composed by various ideas from other layered architectures, like Onion architecture, Hexagonal architecture and Ports and Adapters architecture among others. 51 | 52 | The core idea behind CLEAN is putting the business and the business entities at the center of a software system, and each other layer wrapping the entities. Outer layers are less specific to the business whilst inner layers are all about the business. 53 | 54 | We'll describe briefly what each layer does in CLEAN architecture, in order to understand how we can leverage some of these concepts in our React applications. 55 | 56 | ![diagram.png](docs/img/diagram.png) 57 | 58 | CLEAN architecture, a diagram 59 | 60 | ### Entities 61 | 62 | At the center of the diagram we have entities. In classical CLEAN architecture, entities are a mean of containing state related to business rules. Entities should be plain data structures and have no knowledge of our application framework or UI framework. 63 | 64 | For a frontend application, this is where we have the logic related to the entities of our system. We commonly put these entities into a state management library. We'll discuss about this with more details later on. 65 | 66 | ### **Use Cases** 67 | 68 | Use cases are close to what user stories are in agile terminology. This is where the application business rules live. A use case should represent something a user wants to achieve. Use cases should have all the code to make that happen in a way that makes sense to the application. Notice that use cases can only depend on inner layers, so in order for stuff to happen inside a use case (let's say make an http request) we have to inject dependencies into our use case and apply inversion of control. 69 | 70 | ### **Controllers / Presenters / Gateways** 71 | 72 | This layer contains framework code that implements the use cases. Typically the UI layer would call the methods exposed by the controllers or presenters. 73 | 74 | ### **Framework & Drivers** 75 | 76 | The outermost layer is where all the IO operations are contained. User input, http connections, reading from a web storage, etc. This is where our UI framework lives. 77 | 78 | It is worth noting that as any other layered architecture, we can add as many layers as our system needs. With that being said, let's see how these concepts match with what we usually do with React to implement this architecture on a toy application. 79 | 80 | ## A really convoluted counter application 81 | 82 | We'll talk about each concept on CLEAN architecture through a ~~really convoluted~~ simple counter application. Our application will look something like this: 83 | 84 | ![simple_counter.png](docs/img/simple_counter.png) 85 | 86 | A ~~really convoluted~~ simple counter application 87 | 88 | Let's describe some of the requirements of our application. 89 | 90 | - The initial value should come from a remote data source 91 | - The counter can't be decremented when the counter value is 0 92 | - We should persist the counter value back to our remote data source 93 | 94 | We'll talk about each layer for our counter application: 95 | 96 | ### **Entities** 97 | 98 | In the center of the universe we have our domain entities. In this case we'll define a `Counter` interface with nothing more than a value property. This could also be just a plain type alias for number (`type Counter = number;`). 99 | 100 | It's important to say that this is how we're going to understand a `Counter` entity in the rest of our application, so this definition is kind of the "source of truth" in terms of what a counter is. 101 | 102 | ```tsx 103 | // domain/counterEntity.ts 104 | export interface Counter { 105 | value: number; 106 | } 107 | ``` 108 | 109 | Although we could use a class for representing the data model, an `interface` works just fine. 110 | 111 | ### **Domain model** 112 | 113 | According to [Martin Fowler](https://martinfowler.com/eaaCatalog/domainModel.html): 114 | 115 | > An object model of the domain that incorporates both behavior and data. 116 | > 117 | 118 | Inside our domain model we can define operations over our entities. In this case a simple increment and decrement functions will do. 119 | 120 | Notice that the business rule that the counter value can never go under zero is defined here, right next to the entity definition. 121 | 122 | ```tsx 123 | // domain/counterModel.ts 124 | import type { Counter } from "./counterEntity"; 125 | 126 | const create = (count: Counter["value"]) => ({ value: count }); 127 | const decrement = (counter: Counter) => ({ 128 | value: Math.max(counter.value - 1, 0) 129 | }); 130 | const increment = (counter: Counter) => ({ value: counter.value + 1 }); 131 | 132 | export { create, decrement, increment }; 133 | ``` 134 | 135 | We could put the entity interface and the domain model in the same file and it would be completely fine. 136 | 137 | ### **Data Store (a.k.a repository)** 138 | 139 | This layer is for what we typically think of as state management. However, here we only define the shape of our data access layer, not the implementation. For this we can use an interface. 140 | 141 | ```tsx 142 | // domain/counterStore.ts 143 | import type { Counter } from "./counterEntity"; 144 | 145 | interface CounterStore { 146 | // State 147 | counter: Counter | undefined; 148 | isLoading: boolean; 149 | isUpdating: boolean; 150 | 151 | // Actions 152 | loadInitialCounter(): Promise; 153 | setCounter(counter: Counter): void; 154 | updateCounter(counter: Counter): Promise; 155 | } 156 | 157 | export type { CounterStore }; 158 | ``` 159 | 160 | ### **Use cases** 161 | 162 | As mentioned previously, use cases can be defined as user stories, or things a user (or any other external system) can do with our system. 163 | 164 | There are 3 use cases for our application 165 | 166 | - Get the counter initial value from a data source 167 | - Increment the counter value 168 | - Decrement the counter value 169 | 170 | Notice that updating the counter value in the remote data source is not a use case. That is a side effect of incrementing or decrementing the counter. For this layer it doesn't even matter that the data source is remote. 171 | 172 | **Get counter use case** 173 | 174 | ```tsx 175 | // useCases/getCounterUseCase.ts 176 | import type { CounterStore } from "../domain/counterStore"; 177 | 178 | type GetCounterStore = Pick; 179 | 180 | const getCounterUseCase = (store: GetCounterStore) => { 181 | store.loadInitialCounter(); 182 | }; 183 | 184 | export { getCounterUseCase }; 185 | ``` 186 | 187 | For this particular case we've defined an interface `Store` for the data store (a.k.a repository) that only needs to have a `getCounter` method. Our real `Store` implementation will probably have many more methods, but this is the only thing we care about in this layer. 188 | 189 | **Increment counter use case** 190 | 191 | ```tsx 192 | // useCases/incrementCounterUseCase.ts 193 | import { updateCounterUseCase } from "./updateCounterUseCase"; 194 | import type { UpdateCounterStore } from "./updateCounterUseCase"; 195 | import { increment } from "../domain/counterModel"; 196 | 197 | const incrementCounterUseCase = (store: UpdateCounterStore) => { 198 | return updateCounterUseCase(store, increment); 199 | }; 200 | 201 | export { incrementCounterUseCase }; 202 | ``` 203 | 204 | **Decrement counter use case** 205 | 206 | ```tsx 207 | // useCases/decrementCounterUseCase.ts 208 | import { updateCounterUseCase } from "./updateCounterUseCase"; 209 | import type { UpdateCounterStore } from "./updateCounterUseCase"; 210 | import { decrement } from "../domain/counterModel"; 211 | 212 | const decrementCounterUseCase = (store: UpdateCounterStore) => { 213 | return updateCounterUseCase(store, decrement); 214 | }; 215 | 216 | export { decrementCounterUseCase }; 217 | ``` 218 | 219 | **Update counter use case** 220 | 221 | The two previous use cases use this `updateCounterUseCase` to update the counter value under the hood. As you can see use cases can be composed. 222 | 223 | ```tsx 224 | // useCases/updateCounterUseCase.ts 225 | import debounce from "lodash.debounce"; 226 | 227 | import type { Counter } from "../domain/counterEntity"; 228 | import type { CounterStore } from "../domain/counterStore"; 229 | 230 | type UpdateCounterStore = Pick< 231 | CounterStore, 232 | "counter" | "updateCounter" | "setCounter" 233 | >; 234 | 235 | const debouncedTask = debounce((task) => Promise.resolve(task()), 500); 236 | 237 | const updateCounterUseCase = ( 238 | store: UpdateCounterStore, 239 | updateBy: (counter: Counter) => Counter 240 | ) => { 241 | const updatedCounter = store.counter 242 | ? updateBy(store.counter) 243 | : store.counter; 244 | 245 | if (updatedCounter) { 246 | store.setCounter(updatedCounter); 247 | 248 | return debouncedTask(() => store.updateCounter(updatedCounter)); 249 | } 250 | }; 251 | 252 | export { updateCounterUseCase }; 253 | export type { UpdateCounterStore }; 254 | ``` 255 | 256 | Notice how we debounce the call to `store.updateCounter` here, instead of debouncing the button click. This might feel counterintuitive at first, but now the application logic is contained in a single place rather than spread between too many layers. 257 | 258 | ### **Controllers / Presenters / Gateways** 259 | 260 | As you probably noticed, we haven't written anything specific to React so far: it's only been plain ole TypeScript. This is the first layer where we're going to use React code. 261 | 262 | The role of this layer is to encapsulate *use cases* so they can be called from the UI. For this we can use plain react hooks. 263 | 264 | We'll use a ViewModel kind of pattern here (we'll elaborate more deeply on the role of this component later on): 265 | 266 | ```tsx 267 | // controller/counterViewModel.ts 268 | import React from "react"; 269 | 270 | import type { CounterStore } from "../domain/counterStore"; 271 | import { getCounterUseCase } from "../useCases/getCounterUseCase"; 272 | import { incrementCounterUseCase } from "../useCases/incrementCounterUseCase"; 273 | import { decrementCounterUseCase } from "../useCases/decrementCounterUseCase"; 274 | 275 | function useCounterViewModel(store: CounterStore) { 276 | const getCounter = React.useCallback( 277 | function () { 278 | getCounterUseCase({ 279 | loadInitialCounter: store.loadInitialCounter 280 | }); 281 | }, 282 | [store.loadInitialCounter] 283 | ); 284 | 285 | const incrementCounter = React.useCallback( 286 | function () { 287 | incrementCounterUseCase({ 288 | counter: store.counter, 289 | updateCounter: store.updateCounter, 290 | setCounter: store.setCounter 291 | }); 292 | }, 293 | [store.counter, store.updateCounter, store.setCounter] 294 | ); 295 | 296 | const decrementCounter = React.useCallback( 297 | function () { 298 | decrementCounterUseCase({ 299 | counter: store.counter, 300 | updateCounter: store.updateCounter, 301 | setCounter: store.setCounter 302 | }); 303 | }, 304 | [store.counter, store.updateCounter, store.setCounter] 305 | ); 306 | 307 | return { 308 | count: store.counter?.value, 309 | isLoading: typeof store.counter === "undefined" || store.isLoading, 310 | canDecrement: Number(store.counter?.value) > 0, 311 | getCounter, 312 | incrementCounter, 313 | decrementCounter 314 | }; 315 | } 316 | 317 | export { useCounterViewModel }; 318 | ``` 319 | 320 | The view model not only binds the use cases to framework specific functions, but it also formats the data to semantic variables, so the presentation logic is contained in a single place, rather than scattered throughout the whole view. 321 | 322 | ### **Frameworks & Drivers** 323 | 324 | Ok so this is the outermost layer and here we can have all our specific library code, for this particular example it would mean: 325 | 326 | - React components 327 | - A state management library store implementation 328 | - A counter API service so we can persist the data to the data source 329 | - An HTTP client for talking to the remote data source 330 | - Internationalization 331 | - and much more 332 | 333 | We'll start creating the API service: 334 | 335 | **Counter API Service** 336 | 337 | ```tsx 338 | // counterAPIService.ts 339 | import httpClient from './httpClient'; // this could be an axios instance, I'm skipping this because is not relevant 340 | import type { Counter } from './counterEntity'; 341 | import { create } from './counterModel'; 342 | 343 | const BASE_URL = 'counter'; 344 | 345 | function getCounter(): Promise { 346 | return httpClient.get(BASE_URL).then(res => create(res.data)); 347 | } 348 | 349 | function updateCounter(counter: Counter): Promise { 350 | return httpClient.put(BASE_URL, { count: counter.value }).then(res => create(res.data)); 351 | } 352 | 353 | export { getCounter, updateCounter }; 354 | ``` 355 | 356 | **Data Store Implementation (a.k.a. repository implementation)** 357 | 358 | The beauty about layered architecture is that we don't care about how the hell layers are implemented internally. For the `CounterStoreImplementation` we could use anything: `mobx`, `redux`, `zustand`, `recoil`, a simple React component, whatever, it doesn't matter. 359 | 360 | We'll use `redux` here for good measure, just to demonstrate that the implementation details don't leak into the other layers: 361 | 362 | ```tsx 363 | // data/counterActionTypes.ts 364 | export const SET_COUNTER = "SET_COUNTER"; 365 | export const GET_COUNTER = "GET_COUNTER"; 366 | export const GET_COUNTER_SUCCESS = "GET_COUNTER_SUCCESS"; 367 | export const UPDATE_COUNTER = "UPDATE_COUNTER"; 368 | export const UPDATE_COUNTER_SUCCESS = "UPDATE_COUNTER_SUCCESS"; 369 | ``` 370 | 371 | --- 372 | 373 | ```tsx 374 | // data/counterActions.ts 375 | import type { Counter } from "../domain/counterEntity"; 376 | import { getCounter, updateCounter } from "./counterService"; 377 | import * as actionTypes from "./counterActionTypes"; 378 | 379 | const setCounterAction = (counter: Counter) => (dispatch) => 380 | dispatch({ type: actionTypes.SET_COUNTER, counter }); 381 | 382 | const getCounterAction = () => (dispatch) => { 383 | dispatch({ type: actionTypes.GET_COUNTER }); 384 | 385 | return getCounter().then((counter) => { 386 | dispatch({ type: actionTypes.GET_COUNTER_SUCCESS, counter }); 387 | 388 | return counter; 389 | }); 390 | }; 391 | 392 | const updateCounterAction = (counter: Counter) => (dispatch) => { 393 | dispatch({ type: actionTypes.UPDATE_COUNTER }); 394 | 395 | return updateCounter(counter).then((counter) => { 396 | dispatch({ type: actionTypes.UPDATE_COUNTER_SUCCESS }); 397 | 398 | return counter; 399 | }); 400 | }; 401 | 402 | export { setCounterAction, getCounterAction, updateCounterAction }; 403 | ``` 404 | 405 | --- 406 | 407 | ```tsx 408 | // counterReducer.ts 409 | import type { CounterStore } from "../domain/counterStore"; 410 | import * as actionTypes from "./counterActionTypes"; 411 | 412 | type CounterStoreState = Omit; 413 | 414 | const INITIAL_STATE: CounterStoreState = { 415 | counter: undefined, 416 | isLoading: false, 417 | isUpdating: false 418 | }; 419 | 420 | const counterReducer = (state: CounterStoreState = INITIAL_STATE, action) => { 421 | switch (action.type) { 422 | case actionTypes.SET_COUNTER: 423 | return { ...state, counter: action.counter }; 424 | case actionTypes.GET_COUNTER: 425 | return { ...state, isLoading: true }; 426 | case actionTypes.GET_COUNTER_SUCCESS: 427 | return { ...state, isLoading: false, counter: action.counter }; 428 | case actionTypes.UPDATE_COUNTER: 429 | return { ...state, isUpdating: true }; 430 | case actionTypes.UPDATE_COUNTER_SUCCESS: 431 | return { ...state, isUpdating: false }; 432 | default: 433 | return state; 434 | } 435 | }; 436 | 437 | export { counterReducer }; 438 | export type { CounterStoreState }; 439 | ``` 440 | 441 | With all of our typical redux code in place, only now we can create a counter store implementation for the `CounterStore` interface: 442 | 443 | ```tsx 444 | // data/counterStoreImplementation.ts 445 | import React from "react"; 446 | import { useDispatch, useSelector } from "react-redux"; 447 | 448 | import type { AppRootState } from "../../main/data/appStoreImplementation"; 449 | import type { CounterStore } from "../domain/counterStore"; 450 | import type { Counter } from "../domain/counterEntity"; 451 | 452 | import type { CounterStoreState } from "./counterReducer"; 453 | import { 454 | getCounterAction, 455 | setCounterAction, 456 | updateCounterAction 457 | } from "./counterActions"; 458 | 459 | const counterSelector = (state: AppRootState) => state.counter; 460 | 461 | const useCounterStoreImplementation = (): CounterStore => { 462 | const { counter, isLoading, isUpdating } = useSelector< 463 | AppRootState, 464 | CounterStoreState 465 | >(counterSelector); 466 | const dispatch = useDispatch(); 467 | 468 | const setCounter = React.useCallback( 469 | (counter: Counter) => setCounterAction(counter)(dispatch), 470 | [dispatch] 471 | ); 472 | 473 | const loadInitialCounter = React.useCallback( 474 | () => getCounterAction()(dispatch), 475 | [dispatch] 476 | ); 477 | 478 | const updateCounter = React.useCallback( 479 | (counter: Counter) => updateCounterAction(counter)(dispatch), 480 | [dispatch] 481 | ); 482 | 483 | return { 484 | counter, 485 | isLoading, 486 | isUpdating, 487 | setCounter, 488 | loadInitialCounter, 489 | updateCounter 490 | }; 491 | }; 492 | 493 | export { useCounterStoreImplementation }; 494 | ``` 495 | 496 | **View** 497 | 498 | The last layer we'll show case here is the UI or View layer. This is the integration point for all of our components: 499 | 500 | ```tsx 501 | // view/AppView.tsx 502 | import React from "react"; 503 | 504 | import Button from "../../shared/ui/Button"; 505 | import Count from "../../shared/ui/Count"; 506 | import Spinner from "../../shared/ui/Spinner"; 507 | 508 | import { useCounterViewModel } from "../controller/counterViewModel"; 509 | import { useCounterStoreImplementation } from "../data/counterStoreImplementation"; 510 | 511 | const CounterView = () => { 512 | const store = useCounterStoreImplementation(); 513 | const { 514 | count, 515 | canDecrement, 516 | isLoading, 517 | getCounter, 518 | incrementCounter, 519 | decrementCounter 520 | } = useCounterViewModel(store); 521 | 522 | React.useEffect(() => { 523 | getCounter(); 524 | }, [getCounter]); 525 | 526 | return ( 527 |
528 | {isLoading ? ( 529 | 530 | ) : ( 531 | <> 532 | 535 | {count} 536 | 537 | 538 | )} 539 |
540 | ); 541 | }; 542 | 543 | export default CounterView; 544 | ``` 545 | 546 | ## References 547 | 548 | - Martin Fowler - Catalog of Patterns of Enterprise Application Architecture 549 | [https://martinfowler.com/eaaCatalog/domainModel.html](https://martinfowler.com/eaaCatalog/domainModel.html) 550 | - Denis Brandi - Why you need use cases interactors 551 | [https://proandroiddev.com/why-you-need-use-cases-interactors-142e8a6fe576](https://proandroiddev.com/why-you-need-use-cases-interactors-142e8a6fe576) 552 | - Bob Martin - The Clean Architecture 553 | [https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 554 | - Daniel Mackay - Clean Architecture, an introduction 555 | [https://www.dandoescode.com/blog/clean-architecture-an-introduction/](https://www.dandoescode.com/blog/clean-architecture-an-introduction/) 556 | - CodingWithMitch - 2 Key Concepts of Clean Architecture 557 | [https://www.youtube.com/watch?v=NyJLw3sc17M](https://www.youtube.com/watch?v=NyJLw3sc17M) 558 | - Frank Bos and Fouad Astitou - Fuck CLEAN Architecture 559 | [https://www.youtube.com/watch?v=zkmcy9WQqUE](https://www.youtube.com/watch?v=zkmcy9WQqUE) 560 | - Ian Cooper, The Clean Architecture 561 | [https://www.youtube.com/watch?v=SxJPQ5qXisw](https://www.youtube.com/watch?v=SxJPQ5qXisw) -------------------------------------------------------------------------------- /docs/img/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daslaf/react-clean-architecture/471471fad049600bd24064606a7a4a9bfcc6bc49/docs/img/diagram.png -------------------------------------------------------------------------------- /docs/img/layered_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daslaf/react-clean-architecture/471471fad049600bd24064606a7a4a9bfcc6bc49/docs/img/layered_architecture.png -------------------------------------------------------------------------------- /docs/img/simple_counter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daslaf/react-clean-architecture/471471fad049600bd24064606a7a4a9bfcc6bc49/docs/img/simple_counter.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean-architecture-react", 3 | "version": "1.0.0", 4 | "author": { 5 | "name": "Osman Cea", 6 | "email": "osman.cea@gmail.com", 7 | "url": "https://daslaf.dev" 8 | }, 9 | "description": "A CLEAN architecture implementation for React", 10 | "keywords": ["React", "CLEAN Architecture"], 11 | "main": "src/index.tsx", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@types/lodash.debounce": "4.0.6", 15 | "@types/styled-components": "5.1.14", 16 | "lodash.debounce": "4.0.8", 17 | "react": "17.0.2", 18 | "react-dom": "17.0.2", 19 | "react-redux": "7.2.5", 20 | "react-scripts": "4.0.3", 21 | "redux": "4.1.1", 22 | "redux-thunk": "2.3.0", 23 | "styled-components": "5.3.1" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "17.0.20", 27 | "@types/react-dom": "17.0.9", 28 | "typescript": "4.4.2" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test --env=jsdom", 34 | "eject": "react-scripts eject" 35 | }, 36 | "browserslist": [ 37 | ">0.2%", 38 | "not dead", 39 | "not ie <= 11", 40 | "not op_mini all" 41 | ] 42 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/app/counter/controller/counterViewModel.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import type { CounterStore } from "../domain/counterStore"; 4 | import { getCounterUseCase } from "../useCases/getCounterUseCase"; 5 | import { incrementCounterUseCase } from "../useCases/incrementCounterUseCase"; 6 | import { decrementCounterUseCase } from "../useCases/decrementCounterUseCase"; 7 | 8 | function useCounterViewModel(store: CounterStore) { 9 | const getCounter = React.useCallback( 10 | function () { 11 | getCounterUseCase({ 12 | loadInitialCounter: store.loadInitialCounter 13 | }); 14 | }, 15 | [store.loadInitialCounter] 16 | ); 17 | 18 | const incrementCounter = React.useCallback( 19 | function () { 20 | incrementCounterUseCase({ 21 | counter: store.counter, 22 | updateCounter: store.updateCounter, 23 | setCounter: store.setCounter 24 | }); 25 | }, 26 | [store.counter, store.updateCounter, store.setCounter] 27 | ); 28 | 29 | const decrementCounter = React.useCallback( 30 | function () { 31 | decrementCounterUseCase({ 32 | counter: store.counter, 33 | updateCounter: store.updateCounter, 34 | setCounter: store.setCounter 35 | }); 36 | }, 37 | [store.counter, store.updateCounter, store.setCounter] 38 | ); 39 | 40 | return { 41 | count: store.counter?.value, 42 | isLoading: typeof store.counter === "undefined" || store.isLoading, 43 | canDecrement: Number(store.counter?.value) > 0, 44 | getCounter, 45 | incrementCounter, 46 | decrementCounter 47 | }; 48 | } 49 | 50 | export { useCounterViewModel }; 51 | -------------------------------------------------------------------------------- /src/app/counter/data/counterActionTypes.ts: -------------------------------------------------------------------------------- 1 | export const SET_COUNTER = "SET_COUNTER"; 2 | export const GET_COUNTER = "GET_COUNTER"; 3 | export const GET_COUNTER_SUCCESS = "GET_COUNTER_SUCCESS"; 4 | export const UPDATE_COUNTER = "UPDATE_COUNTER"; 5 | export const UPDATE_COUNTER_SUCCESS = "UPDATE_COUNTER_SUCCESS"; 6 | -------------------------------------------------------------------------------- /src/app/counter/data/counterActions.ts: -------------------------------------------------------------------------------- 1 | import type { Counter } from "../domain/counterEntity"; 2 | import { getCounter, updateCounter } from "./counterService"; 3 | import * as actionTypes from "./counterActionTypes"; 4 | 5 | const setCounterAction = (counter: Counter) => (dispatch: any) => 6 | dispatch({ type: actionTypes.SET_COUNTER, counter }); 7 | 8 | const getCounterAction = () => (dispatch: any) => { 9 | dispatch({ type: actionTypes.GET_COUNTER }); 10 | 11 | return getCounter().then((counter) => { 12 | dispatch({ type: actionTypes.GET_COUNTER_SUCCESS, counter }); 13 | 14 | return counter; 15 | }); 16 | }; 17 | 18 | const updateCounterAction = (counter: Counter) => (dispatch: any) => { 19 | dispatch({ type: actionTypes.UPDATE_COUNTER }); 20 | 21 | return updateCounter(counter).then((counter) => { 22 | dispatch({ type: actionTypes.UPDATE_COUNTER_SUCCESS }); 23 | 24 | return counter; 25 | }); 26 | }; 27 | 28 | export { setCounterAction, getCounterAction, updateCounterAction }; 29 | -------------------------------------------------------------------------------- /src/app/counter/data/counterReducer.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAction } from "redux"; 2 | import type { CounterStore } from "../domain/counterStore"; 3 | import * as actionTypes from "./counterActionTypes"; 4 | 5 | type CounterStoreState = Omit; 6 | 7 | const INITIAL_STATE: CounterStoreState = { 8 | counter: undefined, 9 | isLoading: false, 10 | isUpdating: false 11 | }; 12 | 13 | const counterReducer = (state: CounterStoreState = INITIAL_STATE, action: AnyAction) => { 14 | switch (action.type) { 15 | case actionTypes.SET_COUNTER: 16 | return { ...state, counter: action.counter }; 17 | case actionTypes.GET_COUNTER: 18 | return { ...state, isLoading: true }; 19 | case actionTypes.GET_COUNTER_SUCCESS: 20 | return { ...state, isLoading: false, counter: action.counter }; 21 | case actionTypes.UPDATE_COUNTER: 22 | return { ...state, isUpdating: true }; 23 | case actionTypes.UPDATE_COUNTER_SUCCESS: 24 | return { ...state, isUpdating: false }; 25 | default: 26 | return state; 27 | } 28 | }; 29 | 30 | export { counterReducer }; 31 | export type { CounterStoreState }; 32 | -------------------------------------------------------------------------------- /src/app/counter/data/counterService.ts: -------------------------------------------------------------------------------- 1 | import type { Counter } from "../domain/counterEntity"; 2 | import { create } from "../domain/counterModel"; 3 | 4 | let count = 0; 5 | 6 | function getCounter(): Promise { 7 | return new Promise((resolve) => { 8 | setTimeout(() => { 9 | resolve(create(count)); 10 | }, 1000); 11 | }); 12 | } 13 | 14 | function updateCounter(counter: Counter): Promise { 15 | return new Promise((resolve) => { 16 | setTimeout(() => { 17 | count = counter.value; 18 | resolve(create(count)); 19 | }, 1000); 20 | }); 21 | } 22 | 23 | export { getCounter, updateCounter }; 24 | -------------------------------------------------------------------------------- /src/app/counter/data/counterStoreImplementation.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | 4 | import type { AppRootState } from "../../main/data/appStoreImplementation"; 5 | import type { CounterStore } from "../domain/counterStore"; 6 | import type { Counter } from "../domain/counterEntity"; 7 | 8 | import type { CounterStoreState } from "./counterReducer"; 9 | import { 10 | getCounterAction, 11 | setCounterAction, 12 | updateCounterAction 13 | } from "./counterActions"; 14 | 15 | const counterSelector = (state: AppRootState) => state.counter; 16 | 17 | const useCounterStoreImplementation = (): CounterStore => { 18 | const { counter, isLoading, isUpdating } = useSelector< 19 | AppRootState, 20 | CounterStoreState 21 | >(counterSelector); 22 | const dispatch = useDispatch(); 23 | 24 | const setCounter = React.useCallback( 25 | (counter: Counter) => setCounterAction(counter)(dispatch), 26 | [dispatch] 27 | ); 28 | 29 | const loadInitialCounter = React.useCallback( 30 | () => getCounterAction()(dispatch), 31 | [dispatch] 32 | ); 33 | 34 | const updateCounter = React.useCallback( 35 | (counter: Counter) => updateCounterAction(counter)(dispatch), 36 | [dispatch] 37 | ); 38 | 39 | return { 40 | counter, 41 | isLoading, 42 | isUpdating, 43 | setCounter, 44 | loadInitialCounter, 45 | updateCounter 46 | }; 47 | }; 48 | 49 | export { useCounterStoreImplementation }; -------------------------------------------------------------------------------- /src/app/counter/domain/counterEntity.ts: -------------------------------------------------------------------------------- 1 | export interface Counter { 2 | value: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/counter/domain/counterModel.ts: -------------------------------------------------------------------------------- 1 | import type { Counter } from "./counterEntity"; 2 | 3 | const create = (count: Counter["value"]) => ({ value: count }); 4 | const decrement = (counter: Counter) => ({ 5 | value: Math.max(counter.value - 1, 0) 6 | }); 7 | const increment = (counter: Counter) => ({ value: counter.value + 1 }); 8 | 9 | export { create, decrement, increment }; 10 | -------------------------------------------------------------------------------- /src/app/counter/domain/counterStore.ts: -------------------------------------------------------------------------------- 1 | // counterStore.ts 2 | import type { Counter } from "./counterEntity"; 3 | 4 | interface CounterStore { 5 | // State 6 | counter: Counter | undefined; 7 | isLoading: boolean; 8 | isUpdating: boolean; 9 | 10 | // Actions 11 | loadInitialCounter(): Promise; 12 | setCounter(counter: Counter): void; 13 | updateCounter(counter: Counter): Promise; 14 | } 15 | 16 | export type { CounterStore }; 17 | -------------------------------------------------------------------------------- /src/app/counter/useCases/decrementCounterUseCase.ts: -------------------------------------------------------------------------------- 1 | import { updateCounterUseCase } from "./updateCounterUseCase"; 2 | import type { UpdateCounterStore } from "./updateCounterUseCase"; 3 | import { decrement } from "../domain/counterModel"; 4 | 5 | const decrementCounterUseCase = (store: UpdateCounterStore) => { 6 | return updateCounterUseCase(store, decrement); 7 | }; 8 | 9 | export { decrementCounterUseCase }; 10 | -------------------------------------------------------------------------------- /src/app/counter/useCases/getCounterUseCase.ts: -------------------------------------------------------------------------------- 1 | import type { CounterStore } from "../domain/counterStore"; 2 | 3 | type GetCounterStore = Pick; 4 | 5 | const getCounterUseCase = (store: GetCounterStore) => { 6 | store.loadInitialCounter(); 7 | }; 8 | 9 | export { getCounterUseCase }; 10 | -------------------------------------------------------------------------------- /src/app/counter/useCases/incrementCounterUseCase.ts: -------------------------------------------------------------------------------- 1 | import { updateCounterUseCase } from "./updateCounterUseCase"; 2 | import type { UpdateCounterStore } from "./updateCounterUseCase"; 3 | import { increment } from "../domain/counterModel"; 4 | 5 | const incrementCounterUseCase = (store: UpdateCounterStore) => { 6 | return updateCounterUseCase(store, increment); 7 | }; 8 | 9 | export { incrementCounterUseCase }; 10 | -------------------------------------------------------------------------------- /src/app/counter/useCases/updateCounterUseCase.ts: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce"; 2 | 3 | import type { Counter } from "../domain/counterEntity"; 4 | import type { CounterStore } from "../domain/counterStore"; 5 | 6 | type UpdateCounterStore = Pick< 7 | CounterStore, 8 | "counter" | "updateCounter" | "setCounter" 9 | >; 10 | 11 | const debouncedTask = debounce((task) => Promise.resolve(task()), 500); 12 | 13 | const updateCounterUseCase = ( 14 | store: UpdateCounterStore, 15 | updateBy: (counter: Counter) => Counter 16 | ) => { 17 | const updatedCounter = store.counter 18 | ? updateBy(store.counter) 19 | : store.counter; 20 | 21 | if (!updatedCounter || store.counter?.value === updatedCounter?.value) return; 22 | 23 | store.setCounter(updatedCounter); 24 | 25 | return debouncedTask(() => store.updateCounter(updatedCounter)); 26 | }; 27 | 28 | export { updateCounterUseCase }; 29 | export type { UpdateCounterStore }; 30 | -------------------------------------------------------------------------------- /src/app/counter/view/CounterView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import Button from "../../shared/ui/Button"; 5 | import Spinner from "../../shared/ui/Spinner"; 6 | 7 | import { useCounterViewModel } from "../controller/counterViewModel"; 8 | import { useCounterStoreImplementation } from "../data/counterStoreImplementation"; 9 | 10 | const Count = styled.span` 11 | font-size: 1.375rem; 12 | min-width: 4rem; 13 | display: inline-block; 14 | `; 15 | 16 | const CounterView = () => { 17 | const store = useCounterStoreImplementation(); 18 | const { 19 | count, 20 | canDecrement, 21 | isLoading, 22 | getCounter, 23 | incrementCounter, 24 | decrementCounter 25 | } = useCounterViewModel(store); 26 | 27 | React.useEffect(() => { 28 | getCounter(); 29 | }, [getCounter]); 30 | 31 | return ( 32 |
33 | {isLoading ? ( 34 | 35 | ) : ( 36 | <> 37 | 40 | {count} 41 | 42 | 43 | )} 44 |
45 | ); 46 | }; 47 | 48 | export default CounterView; 49 | -------------------------------------------------------------------------------- /src/app/main/data/appStoreImplementation.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, combineReducers, createStore } from "redux"; 2 | import thunk from "redux-thunk"; 3 | 4 | import { counterReducer } from "../../counter/data/counterReducer"; 5 | 6 | const rootReducer = combineReducers({ 7 | counter: counterReducer 8 | }); 9 | 10 | const appStoreImplementation = createStore(rootReducer, applyMiddleware(thunk)); 11 | 12 | type AppRootState = ReturnType; 13 | 14 | export { appStoreImplementation }; 15 | export type { AppRootState }; 16 | -------------------------------------------------------------------------------- /src/app/main/view/AppView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Provider } from "react-redux"; 3 | import CounterView from "../../counter/view/CounterView"; 4 | 5 | import { appStoreImplementation } from "../data/appStoreImplementation"; 6 | 7 | function AppView() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default AppView; 16 | -------------------------------------------------------------------------------- /src/app/shared/ui/Button.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Button = styled.button` 4 | -webkit-appearance: initial; 5 | display: inline-block; 6 | padding: 0.675rem 1rem; 7 | position: relative; 8 | background-color: ${(props) => (props.disabled ? "#e4e4e4" : "#fff")}; 9 | border: 2px solid #e4e4e4; 10 | border-radius: 0.5rem; 11 | cursor: pointer; 12 | font: inherit; 13 | font-size: 1rem; 14 | font-weight: 400; 15 | text-align: center; 16 | text-decoration: none; 17 | opacity: ${(props) => (props.disabled ? 0.4 : 1)}; 18 | `; 19 | 20 | export default Button; 21 | -------------------------------------------------------------------------------- /src/app/shared/ui/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const SpinnerContainer = styled.div` 5 | position: relative; 6 | width: 40px; 7 | height: 40px; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | 12 | div { 13 | width: 6%; 14 | height: 16%; 15 | position: absolute; 16 | left: 49%; 17 | top: 43%; 18 | opacity: 0; 19 | border-radius: 50px; 20 | animation: fade 1s linear infinite; 21 | background: #4a4a4a; 22 | 23 | &.bar1 { 24 | transform: rotate(0deg) translate(0, -130%); 25 | animation-delay: 0s; 26 | } 27 | 28 | &.bar2 { 29 | transform: rotate(30deg) translate(0, -130%); 30 | animation-delay: -0.9167s; 31 | } 32 | 33 | &.bar3 { 34 | transform: rotate(60deg) translate(0, -130%); 35 | animation-delay: -0.833s; 36 | } 37 | 38 | &.bar4 { 39 | transform: rotate(90deg) translate(0, -130%); 40 | animation-delay: -0.7497s; 41 | } 42 | &.bar5 { 43 | transform: rotate(120deg) translate(0, -130%); 44 | animation-delay: -0.667s; 45 | } 46 | &.bar6 { 47 | transform: rotate(150deg) translate(0, -130%); 48 | animation-delay: -0.5837s; 49 | } 50 | &.bar7 { 51 | transform: rotate(180deg) translate(0, -130%); 52 | animation-delay: -0.5s; 53 | } 54 | &.bar8 { 55 | transform: rotate(210deg) translate(0, -130%); 56 | animation-delay: -0.4167s; 57 | } 58 | &.bar9 { 59 | transform: rotate(240deg) translate(0, -130%); 60 | animation-delay: -0.333s; 61 | } 62 | &.bar10 { 63 | transform: rotate(270deg) translate(0, -130%); 64 | animation-delay: -0.2497s; 65 | } 66 | &.bar11 { 67 | transform: rotate(300deg) translate(0, -130%); 68 | animation-delay: -0.167s; 69 | } 70 | &.bar12 { 71 | transform: rotate(330deg) translate(0, -130%); 72 | animation-delay: -0.0833s; 73 | } 74 | } 75 | 76 | @keyframes fade { 77 | from { 78 | opacity: 1; 79 | } 80 | to { 81 | opacity: 0.25; 82 | } 83 | } 84 | `; 85 | 86 | const Spinner = (): JSX.Element => { 87 | return ( 88 | 89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 102 | ); 103 | }; 104 | 105 | export default Spinner; 106 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "react-dom"; 2 | 3 | import "./styles.css"; 4 | import AppView from "./app/main/view/AppView"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | render(, rootElement); 8 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .App { 2 | font-family: "Fira Code", "Roboto Mono", Menlo, Monaco, "Courier New", Courier, 3 | monospace; 4 | text-align: center; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx", 13 | "target": "es5", 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "allowSyntheticDefaultImports": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true 24 | } 25 | } 26 | --------------------------------------------------------------------------------