├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.json ├── README.md ├── docs ├── ComponentStore-multiple2.md ├── ComponentStore-sinle.md └── Store.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public └── vite.svg ├── src ├── App.css ├── App.tsx ├── entities │ └── CalendarEvent.ts ├── example │ ├── account.example.ts │ ├── activeEvent.example.ts │ ├── counter.example.ts │ ├── example.ts │ ├── mutation.example.ts │ ├── todos.async.example.ts │ ├── todos.example.ts │ ├── wordle.example.ts │ └── wordle.no-example.ts ├── libs │ └── api │ │ ├── adapter │ │ ├── axiosAdapter.ts │ │ └── fetchAdapter.ts │ │ ├── api.example.ts │ │ ├── api.example2.ts │ │ └── apiForge.ts ├── main.tsx ├── routes │ ├── Counter.tsx │ ├── CounterMulti.tsx │ ├── TodoList.tsx │ └── Wordle.tsx ├── test.tsx ├── test │ ├── new.test.ts │ ├── newStore.ts │ ├── proxy.test.ts │ └── reducer.test.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": false, 7 | "bracketSpacing": false, 8 | "bracketSameLine": true, 9 | "singleAttributePerLine": false, 10 | "arrowParens": "always" 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚧 ReactForge 2 | 3 | > 📌 아직 완성되지 않았습니다. 만들어 보고 있는 중입니다! 4 | 5 | - 엔티티 기반 상태관리 도구 6 | - Top-down과 Bottom-up의 하이브리드 방식 7 | 8 | ## Basic Example 9 | 10 | ```ts 11 | // State 12 | interface State { 13 | count:number 14 | doubledCount:number 15 | } 16 | 17 | // Action 18 | interface Actions { 19 | INCREASE(by:number):void 20 | DECREASE(by:number):void 21 | RESET():void 22 | } 23 | ``` 24 | 25 | ```ts 26 | // Store.md 27 | export const useStore = createStore(({store, reducer}) => { 28 | 29 | // Reducer 30 | store.count = reducer(0, (on) => { 31 | on.INCREASE((by) => (state) => (state.count += by)) 32 | on.DECREASE((by) => (state) => (state.count -= by)) 33 | on.RESET(() => (state) => (state.count = 0)) 34 | }) 35 | 36 | // Computed 37 | store.doubledCount = reducer((state) => state.count * 2) 38 | }) 39 | ``` 40 | 41 | You can use store in React. 42 | 43 | ```tsx 44 | // Component 45 | function Counter() { 46 | const {dispatch, count, doubledCount} = useStore() 47 | 48 | const 증가 = () => dispatch.INCREASE(1) 49 | 50 | const 감소 = () => dispatch.DECREASE(1) 51 | 52 | const 초기화 = () => dispatch.RESET() 53 | 54 | return ( 55 | <> 56 |
count is {count}
57 |
doubledCount is {doubledCount}
58 | 59 | 60 | 61 | 62 | ) 63 | } 64 | ``` 65 | 66 | --- 67 | 68 | # ComponentStore 69 | 70 | ## Overview 71 | 72 | ComponentStore is a modern state management library for React, designed to offer a more granular and flexible approach to managing state across components. It enables developers to create separate state management contexts for different parts of their application, reducing the complexity and enhancing the reusability of components. 73 | 74 | ## Key Features 75 | 76 | - **Separate State Contexts:** Enables the creation of separate state contexts (`Providers`) for different components or component groups. 77 | - **Reduced Props Drilling:** By leveraging `Providers`, the need for prop drilling is significantly reduced, leading to cleaner and more maintainable code. 78 | - **Enhanced Reusability:** Components become more reusable and maintainable, as their state management is more self-contained. 79 | - **Flexible State Sharing:** Allows for flexible state sharing and interactions between different state contexts, making it suitable for complex state management scenarios. 80 | 81 | ## Usage 82 | 83 | ### Setting Up ComponentStore 84 | 85 | 1. **createComponentStore** Manages the state of individual todo items. 86 | 87 | ```tsx 88 | interface Todo { 89 | id: string 90 | text: string 91 | completed: boolean 92 | creatorId: string 93 | } 94 | 95 | interface TodoExtra { 96 | creator?: User 97 | 수정권한이_있는가: false 98 | } 99 | 100 | interface TodoActions { 101 | TOGGLE(): void 102 | SET_TEXT(text: string): void 103 | } 104 | 105 | export const [useTodo, TodoProvider, TodoRepo] = createComponentStore(({store: Todo, reducer, key}) => { 106 | // Todo.id = key 107 | 108 | Todo.text = reducer("", (on) => { 109 | on.SET_TEXT((text) => (state) => (state.text = text)) 110 | }) 111 | 112 | Todo.completed = reducer(false, (on) => { 113 | on.TOGGLE(() => (state) => (state.completed = !state.completed)) 114 | }) 115 | }) 116 | ``` 117 | 118 | 119 | 120 | 2. **createStore:** Manages the state of the entire todo list. 121 | 122 | ```tsx 123 | 124 | interface TodoApp { 125 | Todo: Record 126 | 127 | todos: Todo[] 128 | num_todos: number 129 | num_completed_todos: number 130 | } 131 | 132 | interface TodoAppActions { 133 | ADD_TODO(id: string, text: string): void 134 | REMOVE_TODO(id: string): void 135 | } 136 | 137 | export const useTodoApp = createStore(({store, reducer}) => { 138 | // Repository 139 | store.Todo = reducer(TodoRepo, (on) => { 140 | on.ADD_TODO((id, text) => (state) => { 141 | state.Todo[id] = {id, text, completed: false, creatorId: "tmp"} 142 | }) 143 | 144 | on.REMOVE_TODO((id) => (state) => { 145 | delete state.Todo[id] 146 | }) 147 | }) 148 | 149 | // computed value 150 | store.todos = reducer((state) => Object.values(state.Todo).filter(Boolean)) 151 | 152 | store.num_todos = reducer((state) => state.todos.length) 153 | 154 | store.num_completed_todos = reducer((state) => state.todos.filter((todo) => todo.completed).length) 155 | }) 156 | 157 | ``` 158 | 159 | 160 | ### Implementing Components 161 | 162 | 1. **TodoList Component:** Uses `TodoListProvider` to manage the list. 163 | 164 | ```tsx 165 | export default function TodoList() { 166 | const {todos, num_todos, dispatch} = useTodoApp() 167 | 168 | const generateUniqueId = () => Math.random().toString(36).slice(2) 169 | 170 | const addTodo = (e: React.KeyboardEvent) => { 171 | if (e.nativeEvent.isComposing) return 172 | if (e.key === "Enter") { 173 | const text = e.currentTarget.value 174 | const newId = generateUniqueId() 175 | dispatch.ADD_TODO(newId, text) 176 | 177 | e.currentTarget.value = "" 178 | } 179 | } 180 | 181 | return ( 182 | <> 183 |
num_todos: {num_todos}
184 | 185 |
    186 | {todos.map((todo) => ( 187 | // extra value들도 넘길수 있으면 좋겠다. index같은... 188 | 189 | 190 | 191 | ))} 192 |
193 | 194 | ) 195 | } 196 | ``` 197 | 198 | 2. **TodoItem Component:** Manages its own state using `TodoProvider`. 199 | 200 | ```tsx 201 | function TodoItem() { 202 | const {id, text, completed, dispatch} = useTodo() 203 | const app = useTodoApp() 204 | 205 | const toggleTodo = () => dispatch.TOGGLE() 206 | const removeTodo = () => app.dispatch.REMOVE_TODO(id) 207 | 208 | return ( 209 |
  • 210 |
    211 | {id} - {text} 212 |
    213 | 214 |
  • 215 | ) 216 | } 217 | ``` 218 | 219 | --- 220 | /* 여기에 TodoItem의 컴포넌트가 복잡해지면서 기존에는 props-drill이 발생하지만 여기에서는 그렇지 않다는 것을 통해서 뷰 변경의 자유로움을 보여주는 내용과 예시를 추가하자 */ 221 | 222 | ### 기존 방식의 Props Drilling 문제 223 | 224 | - Props를 생성하고 전달하고 특히 Props Type 지정이 너무 괴롭다. 225 | - 추후에 디자인 변경에 따른 컴포넌트 구조 변경이 어려워짐. 226 | 227 | ```tsx 228 | interface TodoItem { 229 | id: string 230 | text: string 231 | completed: boolean 232 | } 233 | 234 | // TodoList 컴포넌트 235 | function TodoList() { 236 | const [todos, setTodos] = useState([{id: "1", text: "Learn React", completed: false}]) 237 | 238 | const toggleTodo = (id: string) => { 239 | // 투두 아이템 상태 변경 로직 240 | } 241 | 242 | return ( 243 |
      244 | {todos.map((todo) => ( 245 | 246 | ))} 247 |
    248 | ) 249 | } 250 | 251 | // TodoItem의 Props 타입 252 | type TodoItemProps = { 253 | todo: TodoItem 254 | onToggle: (id: string) => void 255 | } 256 | 257 | // TodoItem 컴포넌트 258 | function TodoItem({todo, onToggle}: TodoItemProps) { 259 | return ( 260 |
  • 261 | 262 | onToggle(todo.id)} /> 263 |
  • 264 | ) 265 | } 266 | 267 | // TodoText 컴포넌트 268 | function TodoText({text}: {text: string}) { 269 | return {text} 270 | } 271 | 272 | // TodoCheckbox 컴포넌트 273 | function TodoCheckbox({completed, onToggle}: {completed: boolean; onToggle: () => void}) { 274 | return 275 | } 276 | 277 | export default TodoList 278 | ``` 279 | 280 | ### ComponentStore를 사용한 해결 방법 281 | 282 | `ComponentStore`를 사용하면, 각 `TodoItem` 컴포넌트는 자체적으로 상태를 관리할 수 있으며, 상위 컴포넌트로부터 많은 `props`를 전달받을 필요가 없어집니다. 283 | 284 | ```tsx 285 | // TodoItemStore 설정 286 | const [useTodo, TodoProvider, TodoRepo] = createComponentStore<...>(...) 287 | const useTodoApp = createStore<...>(...) 288 | 289 | // TodoList 컴포넌트 290 | function TodoList() { 291 | const {todos, dispatch} = useTodoApp() 292 | 293 | const addTodo = (text) => { 294 | const newId = generateUniqueId() 295 | dispatch.ADD_TODO(newId) 296 | } 297 | 298 | return ( 299 | <> 300 | e.key === "Enter" && addTodo(e.target.value)} /> 301 |
      302 | {todos.map((id) => ( 303 | 304 | 305 | 306 | ))} 307 |
    308 | 309 | ) 310 | } 311 | 312 | // TodoItem 컴포넌트 313 | function TodoItem() { 314 | return ( 315 |
  • 316 | 317 | 318 |
  • 319 | ) 320 | } 321 | 322 | // TodoText 컴포넌트 323 | function TodoText() { 324 | const {text} = useTodo() 325 | return {text} 326 | } 327 | 328 | // TodoCheckbox 컴포넌트 329 | function TodoCheckbox() { 330 | const {completed, dispatch} = useTodo() 331 | const toggleTodo = dispatch.TOGGLE_TODO() 332 | return 333 | } 334 | ``` 335 | 336 | 이 예제에서 `ComponentStore`를 사용하면 `TodoItem` 내부의 `TodoText`와 `TodoCheckbox` 컴포넌트가 상위 컴포넌트로부터 직접 `props`를 전달받지 않고도 필요한 상태에 접근할 수 있습니다. 이로 인해 `Props Drilling` 문제가 해결되고, 컴포넌트 구조가 더 간결하고 유지보수하기 쉬워집니다. 337 | 338 | --- 339 | 340 | ## Key Advantages 341 | 342 | - **Granular State Management:** The use of separate `Providers` for the todo list and individual todo items allows for more detailed and controlled state management. 343 | - **Independent State Management:** Each provider manages its own state independently, reducing inter-component dependencies and enhancing maintainability. 344 | - **Flexible and Efficient State Interactions:** The ability to have different state contexts interact with each other provides a powerful tool for managing complex state behaviors in large-scale applications. 345 | 346 | In conclusion, ComponentStore provides an innovative approach to state management in React applications. It emphasizes modularity, reusability, and flexibility, making it an ideal choice for developers looking to streamline their state management practices in complex applications. 347 | 348 | 349 | 350 | --- 351 | 352 | ### Core Concept 353 | 354 | - 정답이 있는 프레임워크가 되자. 355 | - 강력한 제한을 걸면 코드는 클린해질 수 있다. 356 | - 프레임워크를 쓰는 것 자체가 컨벤션이 될 수 있도록 하자. 357 | - 누가 작성을 해도 클린코드가 될 수 있도록 넛지를 발휘 358 | - 그렇지만 Draft한 개발 과정에서 빠르게 개발을 할 수 있도록 선 개발 후 리팩토링도 가능하게 359 | - 빠른 개발보다 추적과 디버깅을 더 중요하게 생각한다. 360 | - 그렇다고 프레임워크를 사용하는 것이 허들이 되어서는 안된다. 361 | 362 | 363 | ### 원칙 364 | 365 | - 확실한 CQRS 366 | - 함수형 프로그래밍의 컨셉(불변성, 단방향) 367 | - state는 Action을 통해서만 수정을 할 수 있다. 368 | 369 | 370 | ## More Real World App 371 | 372 | ```ts 373 | export interface Todo { 374 | id:number 375 | text:string 376 | completed:boolean 377 | } 378 | 379 | export type VisibilityFilter = "SHOW_ALL"|"SHOW_COMPLETED"|"SHOW_ACTIVE" 380 | 381 | export interface TodoState { 382 | Query: { 383 | todos:Todo[] 384 | filteredTodos:Todo[] 385 | } 386 | Todo: Record 387 | 388 | visibilityFilter:VisibilityFilter 389 | } 390 | 391 | export interface TodoActions { 392 | ADD_TODO(text:string):void 393 | TOGGLE_TODO(id:number):void 394 | REMOVE_TODO(id:number):void 395 | 396 | SET_VISIBILITY_FILTER(filter:VisibilityFilter):void 397 | } 398 | ``` 399 | 400 | ```ts 401 | export const useStore = createStore(({store, reducer}) => { 402 | 403 | store.Todo = reducer([], on => { 404 | on.ADD_TODO((text) => (state) => { 405 | const newTodo = {id: Date.now(), text, completed: false} 406 | state.Todo[id] = newTodo 407 | }) 408 | 409 | on.TOGGLE_TODO((id) => (state) => { 410 | state.Todo[id].completed = !state.Todo[id].completed 411 | }) 412 | 413 | on.REMOVE_TODO((id) => (state) => { 414 | delete state.Todo[id] 415 | }) 416 | }) 417 | 418 | store.Query.todos = reducer(state => Object.values(state.Todo)) 419 | 420 | store.Query.filteredTodos = reducer(state => { 421 | const todos = state.Query.todos 422 | const visibilityFilter = state.visibilityFilter 423 | 424 | if (visibilityFilter === "SHOW_ACTIVE") { 425 | return todos.filter(todo => !todo.completed) 426 | } 427 | 428 | if (visibilityFilter === "SHOW_COMPLETED") { 429 | return todos.filter(todo => todo.completed) 430 | } 431 | 432 | return todos 433 | }) 434 | 435 | store.visibilityFilter = reducer("SHOW_ALL", on => { 436 | on.SET_VISIBILITY_FILTER((filter) => (state) => state.visibilityFilter = filter) 437 | }) 438 | }) 439 | ``` 440 | 441 | 442 | 443 | 444 | ### 주요 개념 445 | 446 | - State 447 | - Store 448 | 449 | 450 | - Action 451 | - Dispatch 452 | - On 453 | - Reducer(+Computed) 454 | 455 | 456 | - Draft 457 | 458 | 459 | 460 | ### 영감을 받은 개념 461 | 462 | - CQRS 463 | - Redux(Single Source, Reducer) 464 | - NgRx(Effect) 465 | 466 | 467 | 468 | ## 특징 469 | 470 | ### 강력한 타입 시스템과 자동완성 471 | - StateForge는 TypeScript의 강력한 타입 시스템을 기반으로 구축되었습니다. 이는 개발 중에 높은 수준의 자동완성 지원을 제공하며, 타입 관련 오류를 사전에 방지할 수 있게 해줍니다. 개발자가 코드를 작성할 때 필요한 속성이나 액션을 쉽게 찾을 수 있도록 도와줍니다. 472 | 473 | ### 최소한의 보일러플레이트 474 | - Redux와 같은 기존의 상태 관리 라이브러리들은 많은 설정과 보일러플레이트 코드가 필요합니다. StateForge는 이런 부분을 대폭 간소화하여 개발자가 비즈니스 로직에 더 집중할 수 있도록 설계되었습니다. 필요한 기능을 몇 줄의 코드로 간결하게 표현할 수 있습니다. 475 | 476 | ### Redux Toolkit + Jotai + Zustand + Valtio = ? 477 | - StateForge는 Redux Toolkit의 영감을 받아 더 나은 개발 경험을 제공합니다. 하지만 StateForge는 타입 안전성과 자동완성을 개선하여 Redux Toolkit이 제공하는 것 이상의 개발자 경험을 제공합니다. 478 | 479 | ### 직관적인 API 480 | - StateForge의 API는 직관적이며 사용하기 쉽습니다. 슬라이스 생성, 액션 정의, 스토어 구성 등의 과정이 단순화되어 있어, 새로운 개발자도 쉽게 상태 관리 시스템을 이해하고 사용할 수 있습니다. 481 | 482 | --- 483 | 484 | ### State & Action 485 | 486 | - Interface를 먼저 설계하고 개발하는 방식 487 | - State와 Action을 분리해서 개발하기 쉽게! BDD, SDD 488 | - 쓸데없은 ActionType, ActionCreator 이런거 NoNo! 489 | - Proxy 기반으로 쓸데없이 불변성을 지키기 위한 코딩 하지 않는다. 490 | - 491 | 492 | --- 493 | 494 | # Core Concept 495 | 496 | ## Store 497 | 498 | "Store"라는 용어는 상점(store)에서 유래했습니다. 상점처럼 다양한 물건을 한 곳에 모아두고 필요할 때 꺼내 쓰는 것과 비슷하게, 상태 관리에서의 store는 애플리케이션의 다양한 데이터(State)를 하나의 장소에 저장하고 필요할 때 컴포넌트가 접근하여 사용할 수 있도록 합니다. 499 | 500 | 이러한 중앙 집중식 관리 방식은 데이터의 일관성을 유지하고, 상태 변화에 대한 추적과 디버깅을 용이하게 합니다. 또한, 애플리케이션의 상태를 한 곳에서 관리함으로써 데이터 흐름을 보다 명확하게 만들고, 복잡한 상태 관리를 단순화하는 데 도움이 됩니다. 501 | 502 | 503 | ### Store의 역할 504 | 505 | - 상태 보관: 애플리케이션의 전체 상태를 하나의 객체로 저장합니다. 506 | - 상태 접근: 컴포넌트에서 store의 상태에 접근할 수 있게 합니다. 507 | - 상태 갱신: 액션을 통해 상태를 변경하고, 이에 대응하는 리듀서로 새로운 상태를 생성합니다. 508 | - 구독 관리: 상태 변화를 구독하고 있는 컴포넌트에 변화를 알립니다. 509 | 510 | 511 | --- 512 | 513 | ## Reducer 514 | 515 | - 요구사항을 구현하는 것는 동작을 데이터로 변경하기 516 | - ex) turnOnLight() vs isLight = true 517 | - 그런데 프로그램이 복잡해지면 값으로만 기술하다 이게 어떤 동작인지 이해가 어려워진다. 518 | - 직접 값을 수정하다 보면 실수를 할 수 있게 된다. 519 | - **"해법: 상태(State)의 변화를 액션과 리듀서로 분리하기"** 520 | 521 | ### 장점: 522 | - 데이터의 변화의 기술을 데이터 scope내에서 작성해서 데이터의 변화를 추적할 수 있다. 523 | - 프로그램을 값의 변화가 아니라 요구사항과 비슷한 형태로 작성할 수 있게 된다. 524 | 525 | ### 단점: 526 | - 조금 더 귀찮아야 한다. 527 | - 문법이 복잡해진다(?) 528 | - => 간단하게 on이라는 helper객체를 제공해서 보일러 플레이트를 줄였다! 529 | - => immer와 같이 불변성을 유지하면서 단순한 문법으로 작성할 수 있게 해결했다. 530 | 531 | 532 | ```ts 533 | store.Todo = reducer([], on => { 534 | on.ADD_TODO((text) => (state) => { 535 | const newTodo = {id: Date.now(), text, completed: false} 536 | state.Todo[id] = newTodo 537 | }) 538 | 539 | on.TOGGLE_TODO((id) => (state) => { 540 | state.Todo[id].completed = !state.Todo[id].completed 541 | }) 542 | 543 | on.REMOVE_TODO((id) => (state) => { 544 | delete state.Todo[id] 545 | }) 546 | }) 547 | ``` 548 | 549 | ### 왜 Reducer에서 state를 직접 수정하나요? 550 | 551 | - 자바스크립트는 불변성을 언어차원에서 지원하지 않으므로 상당히 불편한 코드를 작성해야 합니다. 552 | - ReactForge에서는 자동으로 불변성을 유지하는 코드를 작성해줍니다. (like Immer) 553 | 554 | ```ts 555 | // 결국 순수함수 형태로 제공된다. 556 | function createReducer(state, action, reducerFn) { 557 | const draft = clone(state) // 구조적 공유를 통한 효과적인 복사 558 | const on = helper(action) 559 | reducerFn(on)(draft) 560 | return draft 561 | } 562 | ``` 563 | 564 | --- 565 | ### classicReducer 566 | 567 | > 🤔 (상상) classicReducer도 마이그레이션용으로 제공해 볼까?? 568 | 569 | ```ts 570 | function todoReducer(state = {Todo:{}}, action) { 571 | 572 | switch (action.type) { 573 | case "ADD_TODO": { 574 | const {text} = action.payload 575 | const todo = {id: Date.now(), text, completed: false} 576 | return {...state, Todo: {...state.Todo, [todo.id]: todo}} 577 | } 578 | 579 | case "TOGGLE_TODO": { 580 | const {id} = action.payload 581 | const completed = !state.Todo[id].completed 582 | return {...state, Todo: {...state.Todo, [id]: {...state.Todo[id], completed}}} 583 | } 584 | 585 | case "REMOVE_TODO": { 586 | const {id} = action.payload 587 | const {[id]: value, ...newTodo} = state.Todo; 588 | return {...state, Todo: newTodo}; 589 | delete newState.Todo[id]; 590 | } 591 | } 592 | 593 | return state 594 | } 595 | 596 | store.todo = classicReducer(TodoReducer) 597 | ``` 598 | 599 | --- 600 | 601 | 602 | ## On 603 | 604 | 605 | ```ts 606 | 607 | // on.ACTION(params => (state) => state.value++)) 608 | on.INCREASE(by => (state) => state.count) 609 | 610 | // on 함수를 이용하면, 하나의 함수로 2가지 액션에 대응할 수 있다. 611 | on(on.INCREASE, on.DECREASE)((inc, dec) => (state) => { 612 | acount = inc ? inc : -dec 613 | state.count += amount 614 | }) 615 | 616 | // on 함수와 store를 결합하여 해당 값이 바뀔때 액션으로 받을 수 있다. 617 | // like Rxjs's combineLastest 618 | // each value changes, but every value is not undefined 619 | on(store.account.id, store.User)((accountId, User) => (state) => { 620 | const user = User[accountId] 621 | if(!user) return 622 | state.account.name = user.name 623 | }) 624 | 625 | // SUCCESS or FAILURE or onChange(store.User) 626 | on(on.SUCCESS, on.FAILURE, store.User)((succ, fail, user) => (state) => { 627 | if (succ) state.ok = true 628 | else if (fail) state.ok = false 629 | else state.ok = !!user 630 | }) 631 | ``` 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | # Advanced 647 | 648 | ## Advanced Action 649 | 650 | ### Action Slot Pilling 651 | 652 | ```ts 653 | interface Actions { 654 | ADD_TODO(text:string, id?:number):void 655 | } 656 | 657 | // action middleware 658 | store.dispatch.ADD_TODO = (text:string, id:number = Date.now()) => { 659 | return [text, id] 660 | } 661 | 662 | store.Todo = reducer([], (on) => { 663 | on.ADD_TODO((text, id) => (state) => { 664 | state.Todo[id] = {id, text, complted: false} 665 | }) 666 | 667 | /* Bad 668 | on.ADD_TODO((text) => (state) => { 669 | // Date.now() is not pure! 670 | const newTodo = {id: Date.now(), text, completed: false} 671 | state.Todo[id] = newTodo 672 | }) 673 | */ 674 | }) 675 | 676 | 677 | function Component() { 678 | const someHandler = (msg:string) => dispatch.ADD_TODO(msg) 679 | return <> 680 | } 681 | ``` 682 | 683 | ### Async Action 684 | 685 | Promise를 return하면 SUCCESS, FAILTURE, SETTLED 액션이 자동으로 생성되어 호출된다. 686 | 687 | ```ts 688 | 689 | // async action (promise) 690 | store.dispatch.ADD_TODO_ASYNC = (text:string, id:string) => { 691 | return new Promise(resolve => setTimeout(() => resolve([text, id]), 1000) 692 | } 693 | 694 | store.Todo = reducer({}, on => { 695 | on.ADD_TODO_ASYNC.REQUEST(res => (state) => { 696 | /* ... */ 697 | }) 698 | 699 | on.ADD_TODO_ASYNC.SUCCESS(res => (state) => { 700 | /* ... */ 701 | }) 702 | 703 | on.ADD_TODO_ASYNC.FAILTURE(res => (state) => { 704 | /* ... */ 705 | }) 706 | 707 | on.ADD_TODOADD_TODO.COMPLETED(res => (state) => { 708 | /* ... */ 709 | }) 710 | }) 711 | ``` 712 | 713 | ### Mutation 714 | 715 | 실전 예제: API 연동, 716 | 717 | ```ts 718 | interface Todo { 719 | id:string 720 | text:string 721 | completed:boolean 722 | } 723 | 724 | interface Actions { 725 | ADD_TODO: { 726 | (text:string):(dispatch)=>Promise 727 | REQUEST(todo:Todo) 728 | SUCCESS(todo:Todo) 729 | FAILTURE(error:Error) 730 | } 731 | 732 | REMOVE_TODO: { 733 | 734 | } 735 | } 736 | 737 | store.dispatch.ADD_TODO = (text:string) => (dispatch) => { 738 | const id = Math.random().toString(36).slice(2) 739 | const newTodo = {id, text, completed: false} 740 | return dispatch.REQUEST(newTodo, api.POST("/todos")(newTodo).then(res => res.data)) 741 | } 742 | 743 | store.dispatch.REMOVE_TODO = (id:string) => (dispatch) => { 744 | return dispatch.REQUEST(id, api.DELETE("/todos/:id")(id)) 745 | } 746 | 747 | 748 | store.dispatch.REMOVE_TODO = mutation((id:string) => api.DELETE("/todos/:id")(id)) 749 | 750 | store.dispatch.ADD_TODO = mutation( 751 | (text) => { 752 | const id = Math.random().toString(36).slice(2) 753 | return {id, text, completed: false} 754 | }, 755 | (newTodo) => api.POST("/todos")(newTodo) 756 | ) 757 | 758 | 759 | 760 | 761 | store.dispatch.ADD_TODO = mutation() 762 | .onMutate(text => { 763 | const id = Math.random().toString(36).slice(2) 764 | return {id, text, completed: false} 765 | }) 766 | .mutateFn(newTodo => api.POST("/todos")(newTodo)) 767 | .onSuccess(() => invalidate("/todos/", id) 768 | ) 769 | 770 | 771 | 772 | 773 | store.Todo = reducer([], (on, effect) => { 774 | 775 | on.ADD_TODO.REQUEST(newTodo => (state) => { 776 | state.Todo[id] = newTodo 777 | }) 778 | 779 | on.ADD_TODO.SUCCESS((todo, context) => { 780 | delete state.Todo[context.id] 781 | state.Todo[todo.id] = todo 782 | }) 783 | 784 | // request때 컨텐츠는 자동으로 복원된다. 785 | on.ADD_TODO.FAILTURE((todo, context) => { 786 | /* TODO */ 787 | }) 788 | 789 | on.TOGGLE_TODO((id) => (state) => { 790 | state.Todo[id].completed = !state.Todo[id].completed 791 | }) 792 | 793 | on.REMOVE_TODO.REQUEST((id) => (state) => { 794 | delete state.Todo[id] 795 | }) 796 | }) 797 | ``` 798 | 799 | ```ts 800 | 801 | 802 | store.dispatch.REMOVE_TODO = (id:string) => (dispatch, transaction) => { 803 | return dispatch.REQUEST(id, transaction(state => { 804 | delete state.Todo[id] 805 | return api.DELETE("/todos/:id")(id) 806 | })) 807 | } 808 | 809 | 810 | 811 | 812 | ``` 813 | 814 | 815 | 816 | > 그밖에 Action 확장 생각거리들.. 817 | 818 | 819 | - **.CANCELED**: 진행 중인 비동기 작업을 취소하는 액션입니다. 사용자가 요청을 중단하거나 다른 작업으로 전환할 때 유용합니다. 820 | 821 | 822 | - **.RETRY**: 실패한 비동기 작업을 다시 시도하는 액션입니다. FAILURE 후에 네트워크 상태가 개선되거나 오류가 수정된 경우 유용합니다. 823 | 824 | 825 | - **.THROTTLE / .DEBOUNCE**: 요청의 빈도를 조절하는 액션입니다. 예를 들어, 사용자 입력에 따른 자동 완성 기능에서 서버 부하를 줄이기 위해 사용될 수 있습니다. 826 | 827 | 828 | - **.UPDATE**: 진행 중인 비동기 작업에 대한 중간 업데이트를 제공하는 액션입니다. 예를 들어, 파일 업로드의 진행 상황을 표시할 때 사용될 수 있습니다. 829 | 830 | 831 | - **.POLLING_START / ,POLLING_STOP**: 정기적으로 데이터를 요청하는 폴링(polling) 작업을 시작하거나 중단하는 액션입니다. 실시간 업데이트가 필요한 경우 사용될 수 있습니다. 832 | 833 | --- 834 | 835 | ## 개발 멘탈 모델 836 | 837 | 1. 컴포넌트에서는 1)값과 2)dispatch만 사용한다. 838 | 1. 변하는 값을 value로 만들고 State에 등록한다. 839 | 2. 이벤트 핸들러는 그대로 dispatch하고 Action을 등록한다. 840 | 3. 해당 Action을 하고 나면 어떤 값이 바뀌어야 하는지 생각해본다. 841 | 1. 바뀌는 값의 reducer에 가서 on.ACTION 이후 값을 변화 시킨다. 842 | 4. 요구사항을 생각해본다. 843 | 1. 어떤 값이 바뀌어야 하는가? 844 | 2. 그 값이 바뀌기 위해서 어떤 데이터가 필요한가? 845 | 3. 언제 그값이 바뀌어야 하는가? 846 | 1. 항상 특정 데이터가 추가로 필요하다면 on(store.data.path)를 이용한다. 847 | 2. 특정 시점이 필요하다면 disaptch.ACTION을 통해서 해결한다. 848 | 849 | 850 | 851 | 852 | 853 | 854 | ## 추가 예정 855 | 856 | - ~~비동기 액션 처리~~ 857 | - 이펙트 처리 858 | - 상태 추적 및 디버깅 859 | - 테스트 코드 작성하기 860 | - 상태관리 멘탈 모델 861 | - 조건부 스토리 862 | - 엔티티와 데이터 정규화(Normalize) 863 | - createComponentStore() 864 | - 등등... 865 | - 866 | 867 | --- 868 | 869 | 870 | ## Create API 871 | 872 | 목표 873 | 874 | - fetchXXX, getXXX, 보일러 플레이트 없애기 875 | - d.ts 파일에다가 interface로 등록하면 번들에 포함이 되지 않는다. 876 | - proxy와 typescript를 통해 자동완성 받을 수 있다. 877 | - 이 형식을 스웨거를 통해서 자동생성 할 수 있다 878 | 879 | 880 | ```ts 881 | type Response = createResponse<{ 882 | status:number, 883 | data:State 884 | }> 885 | 886 | interface API_Post { 887 | GET:{ 888 | ["/posts/recommend"]():Response<{lastKey:string, list:Post[]}> 889 | ["/posts/:postId"](postId:string):Response 890 | ["/posts/:postId/comments"](postId:string, params?:unknown):Response 891 | } 892 | } 893 | 894 | interface API_Calendar { 895 | GET:{ 896 | ["/calendars"]():Response 897 | ["/calendars/:calendarId"](calendarId:string):Response 898 | } 899 | 900 | POST:{ 901 | ["/calendars/:calendarId"](calendarId:string, body:Calendar, q:{lastKey:string}):Response 902 | } 903 | 904 | PUT:{ 905 | ["/calendars/:calendarId"]():Response 906 | } 907 | } 908 | 909 | type API 910 | = API_Post 911 | & API_Calendar 912 | 913 | export const api = createAPI({ 914 | baseURL: "https://example.com/api", 915 | fetchOptions: { 916 | /* @TODO: 여기 헤더와 보안 작업 추가 되어야 함.*/ 917 | } 918 | }) 919 | ``` 920 | 921 | ### API 사용방법 922 | 923 | ```ts 924 | // GET /posts/recommend 925 | const res = await api.GET["/posts/recommend"]() 926 | console.log(res.data.data.list) 927 | 928 | // GET /posts/7yKG9ccNK82?lastKey=100 929 | const res2 = await api.GET["/posts/:postId"]("7yKG9ccNK82", {lastKey:100}) 930 | console.log(res2) 931 | 932 | // POST /calendars/7yKG9ccNK82?lastKey=100 body:{x:100} 933 | const res3 = await api.POST["/calendars/:calendarId"]("7yKG9ccNK82", {x:100}, {lastKey:100}) 934 | ``` 935 | 936 | -------------------------------------------------------------------------------- /docs/ComponentStore-multiple2.md: -------------------------------------------------------------------------------- 1 | # ComponentStore 2 | 3 | ## Overview 4 | 5 | ComponentStore is a modern state management library for React, designed to offer a more granular and flexible approach to managing state across components. It enables developers to create separate state management contexts for different parts of their application, reducing the complexity and enhancing the reusability of components. 6 | 7 | ## Key Features 8 | 9 | - **Separate State Contexts:** Enables the creation of separate state contexts (`Providers`) for different components or component groups. 10 | - **Reduced Props Drilling:** By leveraging `Providers`, the need for prop drilling is significantly reduced, leading to cleaner and more maintainable code. 11 | - **Enhanced Reusability:** Components become more reusable and maintainable, as their state management is more self-contained. 12 | - **Flexible State Sharing:** Allows for flexible state sharing and interactions between different state contexts, making it suitable for complex state management scenarios. 13 | 14 | ## Usage 15 | 16 | ### Setting Up ComponentStore 17 | 18 | 1. **createComponentStore** Manages the state of individual todo items. 19 | 20 | ```tsx 21 | interface Todo { 22 | id: string 23 | text: string 24 | completed: boolean 25 | creatorId: string 26 | } 27 | 28 | interface TodoExtra { 29 | creator?: User 30 | 수정권한이_있는가: false 31 | } 32 | 33 | interface TodoActions { 34 | TOGGLE(): void 35 | SET_TEXT(text: string): void 36 | } 37 | 38 | export const [useTodo, TodoProvider, TodoRepo] = createComponentStore(({store: Todo, reducer, key}) => { 39 | // Todo.id = key 40 | 41 | Todo.text = reducer("", (on) => { 42 | on.SET_TEXT((text) => (state) => (state.text = text)) 43 | }) 44 | 45 | Todo.completed = reducer(false, (on) => { 46 | on.TOGGLE(() => (state) => (state.completed = !state.completed)) 47 | }) 48 | }) 49 | ``` 50 | 51 | 52 | 53 | 2. **createStore:** Manages the state of the entire todo list. 54 | 55 | ```tsx 56 | 57 | interface TodoApp { 58 | Todo: Record 59 | 60 | todos: Todo[] 61 | num_todos: number 62 | num_completed_todos: number 63 | } 64 | 65 | interface TodoAppActions { 66 | ADD_TODO(id: string, text: string): void 67 | REMOVE_TODO(id: string): void 68 | } 69 | 70 | export const useTodoApp = createStore(({store, reducer}) => { 71 | // Repository 72 | store.Todo = reducer(TodoRepo, (on) => { 73 | on.ADD_TODO((id, text) => (state) => { 74 | state.Todo[id] = {id, text, completed: false, creatorId: "tmp"} 75 | }) 76 | 77 | on.REMOVE_TODO((id) => (state) => { 78 | delete state.Todo[id] 79 | }) 80 | }) 81 | 82 | // computed value 83 | store.todos = reducer((state) => Object.values(state.Todo).filter(Boolean)) 84 | 85 | store.num_todos = reducer((state) => state.todos.length) 86 | 87 | store.num_completed_todos = reducer((state) => state.todos.filter((todo) => todo.completed).length) 88 | }) 89 | 90 | ``` 91 | 92 | 93 | ### Implementing Components 94 | 95 | 1. **TodoList Component:** Uses `TodoListProvider` to manage the list. 96 | 97 | ```tsx 98 | export default function TodoList() { 99 | const {todos, num_todos, dispatch} = useTodoApp() 100 | 101 | const generateUniqueId = () => Math.random().toString(36).slice(2) 102 | 103 | const addTodo = (e: React.KeyboardEvent) => { 104 | if (e.nativeEvent.isComposing) return 105 | if (e.key === "Enter") { 106 | const text = e.currentTarget.value 107 | const newId = generateUniqueId() 108 | dispatch.ADD_TODO(newId, text) 109 | 110 | e.currentTarget.value = "" 111 | } 112 | } 113 | 114 | return ( 115 | <> 116 |
    num_todos: {num_todos}
    117 | 118 |
      119 | {todos.map((todo) => ( 120 | // extra value들도 넘길수 있으면 좋겠다. index같은... 121 | 122 | 123 | 124 | ))} 125 |
    126 | 127 | ) 128 | } 129 | ``` 130 | 131 | 2. **TodoItem Component:** Manages its own state using `TodoProvider`. 132 | 133 | ```tsx 134 | function TodoItem() { 135 | const {id, text, completed, dispatch} = useTodo() 136 | const app = useTodoApp() 137 | 138 | const toggleTodo = () => dispatch.TOGGLE() 139 | const removeTodo = () => app.dispatch.REMOVE_TODO(id) 140 | 141 | return ( 142 |
  • 143 |
    144 | {id} - {text} 145 |
    146 | 147 |
  • 148 | ) 149 | } 150 | ``` 151 | 152 | --- 153 | /* 여기에 TodoItem의 컴포넌트가 복잡해지면서 기존에는 props-drill이 발생하지만 여기에서는 그렇지 않다는 것을 통해서 뷰 변경의 자유로움을 보여주는 내용과 예시를 추가하자 */ 154 | 155 | ### 기존 방식의 Props Drilling 문제 156 | 157 | - Props를 생성하고 전달하고 특히 Props Type 지정이 너무 괴롭다. 158 | - 추후에 디자인 변경에 따른 컴포넌트 구조 변경이 어려워짐. 159 | 160 | ```tsx 161 | interface TodoItem { 162 | id: string 163 | text: string 164 | completed: boolean 165 | } 166 | 167 | // TodoList 컴포넌트 168 | function TodoList() { 169 | const [todos, setTodos] = useState([{id: "1", text: "Learn React", completed: false}]) 170 | 171 | const toggleTodo = (id: string) => { 172 | // 투두 아이템 상태 변경 로직 173 | } 174 | 175 | return ( 176 |
      177 | {todos.map((todo) => ( 178 | 179 | ))} 180 |
    181 | ) 182 | } 183 | 184 | // TodoItem의 Props 타입 185 | type TodoItemProps = { 186 | todo: TodoItem 187 | onToggle: (id: string) => void 188 | } 189 | 190 | // TodoItem 컴포넌트 191 | function TodoItem({todo, onToggle}: TodoItemProps) { 192 | return ( 193 |
  • 194 | 195 | onToggle(todo.id)} /> 196 |
  • 197 | ) 198 | } 199 | 200 | // TodoText 컴포넌트 201 | function TodoText({text}: {text: string}) { 202 | return {text} 203 | } 204 | 205 | // TodoCheckbox 컴포넌트 206 | function TodoCheckbox({completed, onToggle}: {completed: boolean; onToggle: () => void}) { 207 | return 208 | } 209 | 210 | export default TodoList 211 | ``` 212 | 213 | ### ComponentStore를 사용한 해결 방법 214 | 215 | `ComponentStore`를 사용하면, 각 `TodoItem` 컴포넌트는 자체적으로 상태를 관리할 수 있으며, 상위 컴포넌트로부터 많은 `props`를 전달받을 필요가 없어집니다. 216 | 217 | ```tsx 218 | // TodoItemStore 설정 219 | const useTodoApp = createStore<...>(...) 220 | const [TodoProvider, useTodo] = createComponentStore<...>(...) 221 | 222 | // TodoList 컴포넌트 223 | function TodoList() { 224 | const {todos, dispatch} = useTodoApp() 225 | 226 | const addTodo = (text) => { 227 | const newId = generateUniqueId() 228 | dispatch.ADD_TODO(newId) 229 | } 230 | 231 | return ( 232 | <> 233 | e.key === "Enter" && addTodo(e.target.value)} /> 234 |
      235 | {todos.map((id) => ( 236 | 237 | 238 | 239 | ))} 240 |
    241 | 242 | ) 243 | } 244 | 245 | // TodoItem 컴포넌트 246 | function TodoItem() { 247 | return ( 248 |
  • 249 | 250 | 251 |
  • 252 | ) 253 | } 254 | 255 | // TodoText 컴포넌트 256 | function TodoText() { 257 | const {text} = useTodo() 258 | return {text} 259 | } 260 | 261 | // TodoCheckbox 컴포넌트 262 | function TodoCheckbox() { 263 | const {completed, dispatch} = useTodo() 264 | const toggleTodo = dispatch.TOGGLE_TODO() 265 | return 266 | } 267 | ``` 268 | 269 | 이 예제에서 `ComponentStore`를 사용하면 `TodoItem` 내부의 `TodoText`와 `TodoCheckbox` 컴포넌트가 상위 컴포넌트로부터 직접 `props`를 전달받지 않고도 필요한 상태에 접근할 수 있습니다. 이로 인해 `Props Drilling` 문제가 해결되고, 컴포넌트 구조가 더 간결하고 유지보수하기 쉬워집니다. 270 | 271 | 272 | 273 | --- 274 | 275 | ## Key Advantages 276 | 277 | - **Granular State Management:** The use of separate `Providers` for the todo list and individual todo items allows for more detailed and controlled state management. 278 | - **Independent State Management:** Each provider manages its own state independently, reducing inter-component dependencies and enhancing maintainability. 279 | - **Flexible and Efficient State Interactions:** The ability to have different state contexts interact with each other provides a powerful tool for managing complex state behaviors in large-scale applications. 280 | 281 | In conclusion, ComponentStore provides an innovative approach to state management in React applications. It emphasizes modularity, reusability, and flexibility, making it an ideal choice for developers looking to streamline their state management practices in complex applications. -------------------------------------------------------------------------------- /docs/ComponentStore-sinle.md: -------------------------------------------------------------------------------- 1 | 알겠습니다. `ComponentStore`의 강점 중 하나는 `Provider`의 `id`를 사용하여 별도의 `Props` 전달 없이 상태를 관리하고 공유할 수 있다는 점입니다. 이를 활용하여 투두 아이템과 투두 리스트를 구현하는 예제를 수정하겠습니다. 각 투두 아이템은 자체 `Provider`를 사용하고, `id`를 기반으로 상태를 관리하여 `Props` 전달이 필요 없게 됩니다. 2 | 3 | ### 예제: 투두 리스트와 투두 아이템의 상태 관리 4 | 5 | #### 1. Store 설정 6 | 7 | 투두 리스트와 투두 아이템의 상태를 관리할 `Store`를 설정합니다. 8 | 9 | ```tsx 10 | import { createComponentStore } from 'componentstore'; 11 | 12 | interface TodoItemState { 13 | id: number; 14 | text: string; 15 | completed: boolean; 16 | } 17 | 18 | interface TodoListState { 19 | todos: Record; 20 | } 21 | 22 | interface TodoListActions { 23 | ADD_TODO(text: string): void; 24 | TOGGLE_TODO(id: string): void; 25 | } 26 | 27 | const [TodoListProvider, useTodoApp] = createComponentStore(); 28 | ``` 29 | 30 | #### 2. 투두 리스트 컴포넌트 구현 31 | 32 | 투두 리스트 컴포넌트에서는 각 투두 아이템을 `Provider`와 함께 렌더링합니다. 각 아이템은 고유한 `id`를 사용합니다. 33 | 34 | ```tsx 35 | function TodoList() { 36 | const { state, dispatch } = useTodoApp(); 37 | 38 | const addTodo = (text) => { 39 | const newId = generateUniqueId(); // 고유 ID 생성 함수 40 | dispatch.ADD_TODO(newId, text); 41 | }; 42 | 43 | return ( 44 | <> 45 | e.key === 'Enter' && addTodo(e.target.value)} /> 46 |
      47 | {Object.values(state.todos).map(todo => ( 48 | 49 | 50 | 51 | ))} 52 |
    53 | 54 | ); 55 | } 56 | ``` 57 | 58 | #### 3. 투두 아이템 컴포넌트 구현 59 | 60 | 각 투두 아이템은 자신의 상태를 관리합니다. `Props`는 필요 없으며, 상태는 `Provider`의 `id`를 통해 관리됩니다. 61 | 62 | ```tsx 63 | function TodoItem() { 64 | const { state, dispatch } = useTodoApp(); 65 | 66 | const toggleTodo = () => { 67 | dispatch.TOGGLE_TODO(state.id); 68 | }; 69 | 70 | return ( 71 |
  • 75 | {state.text} 76 |
  • 77 | ); 78 | } 79 | ``` 80 | 81 | ### 강점 82 | 83 | - **Props 전달 없는 상태 관리:** 각 투두 아이템은 `Provider`의 `id`를 통해 상태를 관리합니다. 이는 `Props` 전달을 완전히 제거하며, 코드의 간결성과 가독성을 향상시킵니다. 84 | - **상태 공유와 독립성:** 각 투두 아이템은 독립적으로 상태를 관리하면서도 전체 투두 리스트의 상태와 연동됩니다. 이는 상태 관리의 효율성을 높입니다. 85 | - **재사용성과 유연성:** `Provider`를 사용하여 동일한 구조의 여러 컴포넌트에서 상태를 공유하고 재사용할 수 있습니다. 86 | 87 | 이 예제는 `ComponentStore`의 강력한 상태 관리 능력을 보여줍니다. `Props` 전달 없이도 컴포넌트 간의 상태 공유 및 관리가 가능하며, 이는 특히 복잡한 애플리 -------------------------------------------------------------------------------- /docs/Store.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developer-1px/ReactForge/f9c6955d6720806f3ed15c45aa02d3c312a5cbc9/docs/Store.md -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "state-forge", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "test": "vitest", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.37", 19 | "@types/react-dom": "^18.2.15", 20 | "@typescript-eslint/eslint-plugin": "^6.10.0", 21 | "@typescript-eslint/parser": "^6.10.0", 22 | "@vitejs/plugin-react": "^4.1.1", 23 | "adorable-css": "^1.6.0", 24 | "axios": "^1.6.1", 25 | "eslint": "^8.53.0", 26 | "eslint-plugin-react-hooks": "^4.6.0", 27 | "eslint-plugin-react-refresh": "^0.4.4", 28 | "prettier": "^3.1.0", 29 | "react-router-dom": "^6.20.1", 30 | "rxjs": "^7.8.1", 31 | "typescript": "^5.2.2", 32 | "vite": "^4.5.0", 33 | "vitest": "^0.34.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | button { 2 | padding: 10px; 3 | background: #eee; 4 | border-radius: 8px; 5 | margin: 2px; 6 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import {Link, Outlet, Route, Routes} from "react-router-dom" 2 | import CounterApp from "./routes/Counter.tsx" 3 | import "./App.css" 4 | import CounterStoreApp from "./routes/CounterMulti.tsx" 5 | import TodoList from "./routes/TodoList.tsx" 6 | import Wordle from "./routes/Wordle.tsx" 7 | 8 | export default function App() { 9 | return ( 10 | <> 11 | 12 | }> 13 | } /> 14 | } /> 15 | } /> 16 | } /> 17 | } /> 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | function Layout() { 25 | return ( 26 |
    27 | {/* A "layout route" is a good place to put markup you want to 28 | share across all the pages on your site, like navigation. */} 29 | 45 | 46 |
    47 | 48 | {/* An renders whatever child route is currently active, 49 | so you can think about this as a placeholder for 50 | the child routes we defined above. */} 51 | 52 |
    53 |
    54 | 55 |
    56 |
    57 |
    58 | ) 59 | } 60 | 61 | function NoMatch() { 62 | return ( 63 |
    64 |

    Nothing to see here!

    65 |

    66 | Go to the home page 67 |

    68 |
    69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/entities/CalendarEvent.ts: -------------------------------------------------------------------------------- 1 | import type {DateTime} from "src/libs/fp/date" 2 | import type {Attendee} from "src/types/Attendee" 3 | import {createStorePart} from "../test/newStore.ts" 4 | 5 | type EventStatus = "confirmed" | "tentative" | "cancelled" 6 | type GadgetDisplayMode = string 7 | type ReminderMethod = string 8 | type EventTransparency = "opaque" | "transparent" 9 | type EventVisibility = "default" | "public" | "private" | "confidential" 10 | 11 | export interface CalendarEvent { 12 | eventType?: string 13 | kind: "calendar#event" 14 | etag: string 15 | id: string 16 | calendarId: string 17 | status?: EventStatus 18 | htmlLink: string 19 | created: DateTime 20 | updated: DateTime 21 | summary: string 22 | description: string 23 | location: string 24 | colorId: string | null 25 | 26 | calendar: { 27 | id: string 28 | summary: string 29 | kakaoworkUserId: number 30 | primary: boolean 31 | } 32 | 33 | // The creator of the event. Read-only. 34 | creator: { 35 | id?: string 36 | email?: string 37 | displayName?: string 38 | self?: boolean 39 | kakaoworkUserId?: number 40 | } 41 | 42 | // The organizer of the event. 43 | organizer: { 44 | id?: string 45 | email?: string 46 | displayName?: string 47 | self?: boolean 48 | kakaoworkUserId?: number 49 | } 50 | 51 | start: { 52 | date?: string 53 | dateTime?: string 54 | timeZone?: string 55 | } 56 | 57 | end: { 58 | date?: string 59 | dateTime?: string 60 | timeZone?: string 61 | } 62 | 63 | endTimeUnspecified?: boolean 64 | 65 | recurrence: string[] 66 | 67 | // For an instance of a recurring event, this is the id of the recurring event to which this instance belongs. Immutable. 68 | recurringEventId?: string 69 | 70 | // Whether the organizer corresponds to the calendar on which this copy of the event appears. Read-only. The default is False. 71 | originalStartTime?: { 72 | date: string 73 | dateTime: string 74 | timeZone?: string 75 | } 76 | 77 | transparency?: EventTransparency 78 | visibility?: EventVisibility 79 | iCalUID: string 80 | sequence: number 81 | 82 | // The attendees of the event. 83 | attendees: Attendee[] 84 | 85 | attendeesOmitted?: boolean 86 | 87 | // Extended properties of the event. 88 | extendedProperties?: { 89 | private: { 90 | (key: string): string 91 | } 92 | shared: { 93 | (key: string): string 94 | } 95 | } 96 | 97 | // An absolute link to the Google+ hangout associated with this event. Read-only. 98 | hangoutLink?: string 99 | 100 | // A gadget that extends this event. 101 | gadget?: { 102 | type: string 103 | title: string 104 | link: string 105 | iconLink: string 106 | width?: number 107 | height?: number 108 | display?: GadgetDisplayMode 109 | preferences: { 110 | (key: string): string 111 | } 112 | } 113 | 114 | anyoneCanAddSelf?: boolean 115 | guestsCanInviteOthers?: boolean 116 | guestsCanModify?: boolean 117 | guestsCanSeeOtherGuests?: boolean 118 | privateCopy?: boolean 119 | 120 | // Whether this is a locked event copy where no changes can be made to the main event fields "summary", "description", "location", "start", "end" or "recurrence". The default is False. Read-Only. 121 | locked?: boolean 122 | 123 | reminders: { 124 | useDefault: boolean 125 | overrides?: { 126 | method: ReminderMethod 127 | minutes: number 128 | }[] 129 | } 130 | 131 | // Source from which the event was created. For example, a web page, an email message or any document identifiable by an URL with HTTP or HTTPS scheme. 132 | // Can only be seen or modified by the creator of the event. 133 | source?: { 134 | url: string 135 | title: string 136 | } 137 | 138 | // File attachments for the event. Currently only Google Drive attachments are supported. 139 | attachments?: { 140 | fileUrl: string 141 | title: string 142 | mimeType: string 143 | iconLink: string 144 | fileId: string 145 | }[] 146 | 147 | responseStatus: string 148 | backgroundColor: string 149 | primary: boolean 150 | conferenceData?: string 151 | deleted?: boolean 152 | } 153 | 154 | // custom ui 155 | export interface CalendarEvent { 156 | type?: string 157 | collUuid?: string 158 | } 159 | 160 | // custom ui 161 | export interface CalendarEvent { 162 | key: string 163 | 164 | start_dateTime: DateTime 165 | end_dateTime: DateTime 166 | hasLink: boolean 167 | 168 | _isNew?: boolean 169 | _originalCalendarId: string 170 | 171 | isAllDay: boolean 172 | allDayRange?: number 173 | 174 | recurrenceFormat?: string 175 | recurrence_option_index?: number 176 | 177 | isActive?: boolean 178 | isDragging?: boolean 179 | original_key?: string 180 | dragProxy?: boolean 181 | isGrayProxy?: boolean 182 | 183 | duration?: number 184 | allDay?: { 185 | index?: number 186 | start?: number 187 | end?: number 188 | } 189 | 190 | // @FIXME: view용 일정겹침 191 | _indent?: number 192 | 193 | 일정변경_및_관리권한이_있는가: boolean 194 | } 195 | 196 | interface CalendarEventActions { 197 | 빈시간선택(): void 198 | } 199 | 200 | interface Account { 201 | id: number 202 | } 203 | 204 | interface Repository { 205 | CalendarEvents: Record 206 | CalendarLists: Record 207 | account: Account 208 | activeEvent: CalendarEvent 209 | } 210 | 211 | const {store: CalendarEvent, reducer} = createStorePart() 212 | 213 | const atom = reducer 214 | const usecase = (fn: (repo: Repository) => (state: CalendarEvent) => boolean): T => {} 215 | 216 | CalendarEvent.dragProxy = atom((event) => event.id === "dragProxy") 217 | 218 | CalendarEvent.isActive = usecase((repo) => (event) => event.id === repo.activeEvent.id) 219 | 220 | CalendarEvent.일정변경_및_관리권한이_있는가 = usecase((repo) => (event) => { 221 | const {CalendarLists, account} = repo 222 | 223 | // 회의실 예약시스템 일정은 일정 변경 및 관리 권한이 없다. 224 | if (GRC_회의실_예약시스템_일정인가(event)) { 225 | return false 226 | } 227 | 228 | const accessRole = CalendarLists[event.calendarId].accessRole 229 | 230 | // "owner"거나 "delegator"면 수정 가능 231 | if (accessRole === "owner" || accessRole === "delegator") { 232 | return true 233 | } 234 | 235 | // "writer"이면서 새 일정은 OK 236 | if (accessRole === "writer" && !event._isNew) { 237 | return true 238 | } 239 | 240 | // "writer"이면서 내가 생성한 일정은 수정 가능 241 | if (accessRole === "writer" && event.id) { 242 | const 현재_캘린더_ID = event.calendarId 243 | const 원본_캘린더_ID = event.calendar.id 244 | const creatorId = event.creator.kakaoworkUserId 245 | 246 | if (현재_캘린더_ID === 원본_캘린더_ID && creatorId === account?.id) { 247 | return true 248 | } 249 | } 250 | 251 | return false 252 | }) 253 | 254 | start = DateTime.from(start).startOf("day").add({days: +NUM_WEEK_OFFSET}) 255 | end = start.add({days: +NUM_WEEK}) 256 | 257 | CalendarEvent.allDay = {index: 0} 258 | CalendarEvent.allDay.start = atom((event) => Math.ceil((+event.start_dateTime - +start) / 1000 / 60 / 60 / 24)) 259 | CalendarEvent.allDay.end = atom((event) => Math.ceil((+event.end_dateTime - +start) / 1000 / 60 / 60 / 24)) 260 | -------------------------------------------------------------------------------- /src/example/account.example.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | 3 | interface User { 4 | id: string 5 | email: string 6 | display_name: string 7 | avatar_url: string 8 | space_id: string 9 | grid?: { 10 | id: string 11 | } 12 | } 13 | 14 | interface Account extends User {} 15 | 16 | interface State { 17 | User: Record 18 | 19 | account: Account | null 20 | 그룹사_기능_사용중인가: boolean 21 | 타_그룹사인가(userId: string): boolean 22 | } 23 | 24 | interface Actions { 25 | API_FETCH_CALENDAR_LIST_SUCCESS(calendarList: Array): void 26 | 계정정보조회하기_SUCCESS(account: Account): void 27 | 계정정보_동기화(user: User): void 28 | } 29 | 30 | export default createStore(({store, reducer}) => { 31 | // 32 | store.account = reducer( 33 | (state) => { 34 | if (!state.account) { 35 | return null 36 | } 37 | 38 | const user = state.User[state.account.id] 39 | if (user) { 40 | state.account.display_name = user.display_name 41 | state.account.avatar_url = user.avatar_url 42 | } 43 | 44 | return state.account 45 | }, 46 | (on) => { 47 | // 48 | on.API_FETCH_CALENDAR_LIST_SUCCESS((calendarList) => (state) => { 49 | if (state.account) return 50 | 51 | const id = calendarList.find((c) => c.primary)?.kakaoworkUserId 52 | if (!id) return 53 | 54 | state.account = { 55 | id, 56 | email: "", 57 | display_name: "", 58 | avatar_url: "", 59 | space_id: "", 60 | } 61 | }) 62 | 63 | on.계정정보조회하기_SUCCESS((account) => (state) => (state.account = account)) 64 | } 65 | ) 66 | 67 | store.타_그룹사인가 = reducer((state) => (userId: string) => { 68 | const account = state.account 69 | if (!account) return false 70 | 71 | const space_id = state.User[userId]?.space_id 72 | if (!space_id) { 73 | return false 74 | } 75 | 76 | return space_id !== account.space_id 77 | }) 78 | 79 | store.그룹사_기능_사용중인가 = reducer((state) => !!state.account?.grid?.id) 80 | }) 81 | -------------------------------------------------------------------------------- /src/example/activeEvent.example.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | 3 | interface CalendarEvent {} 4 | 5 | interface State { 6 | activeEvent: CalendarEvent 7 | dispatch: Actions 8 | } 9 | 10 | interface Actions { 11 | 반복일정상세조회(calendarId: string, recurringEventId: string): Promise<{calendarId: string; recurringEventId: string}> 12 | } 13 | 14 | const {store, reducer} = createStore() 15 | 16 | store.activeEvent = reducer(null, (on, effect) => { 17 | // 18 | // effect의 목적: state의 변화를 감지하고 원하는 action의 시점을 자동화 19 | // 사이드 이펙트는 action을 통해서 분리한다. 20 | // state를 변경하지 못하는 것은 아니나... 가급적 dispatch로 끝내는 것을 추천하다. (경고 삽업 예정!) 21 | effect("[공통][다이나믹패널] 선택된 일정의 반복주기를 조회하고 표기한다.", (track) => (state, dispatch) => { 22 | const [_originalCalendarId, recurringEventId] = track((state) => [ 23 | state.activeEvent._originalCalendarId, 24 | state.activeEvent.recurringEventId, 25 | state.activeEvent.updated, 26 | ]) 27 | if (!_originalCalendarId || !recurringEventId) { 28 | return 29 | } 30 | 31 | const {_originalCalendarId, recurringEventId} = state.activeEvent 32 | dispatch.반복일정상세조회(_originalCalendarId, recurringEventId) 33 | }) 34 | }) 35 | 36 | store.Calendar = reducer({}, (on) => { 37 | on.반복일정상세조회.SUCCESS(({calendarId, recurringEventId, recurrenceEvent}) => (state) => { 38 | state.Calendar[calendarId].recurringEvents[recurringEventId] = { 39 | ...state.Calendar[calendarId].recurringEvents[recurringEventId], 40 | recurrenceEvent, 41 | } 42 | }) 43 | }) 44 | 45 | // middleware? effect? whatever? 46 | // 사이드 이펙트 격리 47 | // 테스트도 쉽고, 추후 mock에도 용이하도록 만들 수 있다. 48 | store.dispatch.반복일정상세조회 = async (calendarId, recurringEventId) => { 49 | const recurringEvent = await api.GET["/calendarEvent"](calendarId, recurringEventId) 50 | if (!recurringEvent?.recurrence) { 51 | throw Error("반복일정이 아님!") 52 | } 53 | 54 | recurrenceEvent.recurrenceFormat = getRecurrenceFormatFromRecurringEvent(recurringEvent) 55 | return {calendarId, recurringEventId, recurrenceEvent} 56 | } 57 | 58 | // 테스트용 mock을 만들수도 있다! 59 | if (import.meta.vitest) { 60 | store.dispatch.반복일정상세조회 = async (calendarId, recurringEventId) => { 61 | const mock = {} 62 | return {calendarId, recurringEventId, recurrenceEvent: mock} 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/example/counter.example.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | 3 | interface State { 4 | count: number 5 | doubledCount: number 6 | } 7 | 8 | interface Actions { 9 | INCREASE(by: number): void 10 | DECREASE(by: number): void 11 | RESET(): void 12 | } 13 | 14 | const {store, reducer} = createStore() 15 | 16 | store.count = reducer(0, (on) => { 17 | on.INCREASE((by) => (state) => (state.count += by)) 18 | on.DECREASE((by) => (state) => (state.count -= by)) 19 | on.RESET(() => (state) => (state.count = 0)) 20 | }) 21 | 22 | store.doubledCount = reducer((state) => state.count * 2) 23 | -------------------------------------------------------------------------------- /src/example/example.ts: -------------------------------------------------------------------------------- 1 | interface Actions { 2 | INCREASE(by: number): void 3 | DECREASE(by: number): void 4 | RESET(): void // Reset shouldn't take a parameter. 5 | } 6 | 7 | type On = { 8 | [K in keyof Actions]: (arg: Actions[K] extends (arg: infer U) => void ? U : never) => State 9 | } 10 | 11 | const reducer = (init: T, fn: (on: On, state: T) => void) => { 12 | let state: T 13 | 14 | const dispatch = {} as Actions 15 | 16 | return [() => state, dispatch] as const 17 | } 18 | 19 | const [count, dispatch] = reducer(0, (on, count) => { 20 | on.INCREASE((by) => count + by) 21 | on.DECREASE((by) => count - by) 22 | on.RESET((by) => 0) 23 | }) 24 | 25 | const c = reducer(0, (on) => { 26 | on.INCREASE((by) => (count) => count + by) 27 | }) 28 | -------------------------------------------------------------------------------- /src/example/mutation.example.ts: -------------------------------------------------------------------------------- 1 | store.dispatch.내_캘린더_만들기 = ({summary, backgroundColor, description = "", acl = []}) => { 2 | const calendar = await create_calendars({summary, backgroundColor, description}) 3 | const calendarId = calendar.id 4 | 5 | const {items} = await fetch_calendars_acl(calendarId) 6 | const organization = items.find((r) => r.scope.type === "organization") 7 | const grid = items.find((r) => r.scope.type === "grid") 8 | 9 | if (!organization?.id) { 10 | throw new Error("[_내_캘린더_만들기] organization has not id.") 11 | } 12 | 13 | await Promise.all([ 14 | ...acl.map((acl) => { 15 | const {scope, role} = acl 16 | 17 | if (scope.type === "organization") { 18 | return patch_calendars_acl(calendarId, organization.id, {role}) 19 | } 20 | 21 | if (scope.type === "grid") { 22 | return patch_calendars_acl(calendarId, grid.id, {role}) 23 | } 24 | 25 | return insert_calendars_acl(calendarId, acl) 26 | }), 27 | ]) 28 | 29 | return calendar 30 | } 31 | 32 | interface State { 33 | Query: {} 34 | } 35 | 36 | store.Query.useCalendarList = (params) => 37 | useQuery({ 38 | key: (cache) => cache.calendarList, 39 | fn: () => api.GET["/calendarList"](params), 40 | select: (res) => res.items || [], 41 | retry: 3, 42 | }) 43 | 44 | store.Query.useCalendarListItem = (id) => 45 | useQuery({ 46 | key: (cache) => cache.calendarList[id], 47 | fn: () => api.GET["/calendarList/:id"](id), 48 | select: (res) => res.items || [], 49 | dedupingStrategy: "cancel", // or "skip", "queue", "" 50 | retry: 3, 51 | }) 52 | 53 | const {Query} = useStore() 54 | 55 | const calendarList = Query.useCalendarList({lastKey: 10}) 56 | 57 | if (calendarList.isLoading) return 58 | 59 | on(_API_FETCH_CALENDAR_LIST) 60 | .mergeMap((request) => 61 | dispatch( 62 | _API_FETCH_CALENDAR_LIST.REQUEST, 63 | fetch_calendarList(request).map((result) => result.items || []) 64 | ) 65 | ) 66 | .createEffect() 67 | -------------------------------------------------------------------------------- /src/example/todos.async.example.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | 3 | interface Todo { 4 | id: string 5 | title: string 6 | completed: boolean 7 | } 8 | 9 | type VisibilityFilter = "SHOW_ALL" | "SHOW_ACTIVE" | "SHOW_COMPLETED" 10 | 11 | interface State { 12 | Query: { 13 | todos: Todo[] 14 | filteredTodos: Todo[] 15 | numRemainingTodos: number 16 | } 17 | Todo: Record 18 | 19 | visibilityFilter: VisibilityFilter 20 | } 21 | 22 | interface Actions { 23 | ADD_TODO(title: string): void 24 | TOGGLE_TODO(id: string): void 25 | REMOVE_TODO(id: string): void 26 | REMOVE_ALL(): void 27 | CLEAR_COMPLETED(): void 28 | } 29 | 30 | const {store, reducer} = createStore() 31 | 32 | const auth = createAPI() 33 | 34 | store.Todo = reducer({}, (on) => { 35 | on.ADD_TODO((title) => (state) => { 36 | const id = Math.random().toString(36).slice(2) 37 | state.Todo[id] = {id, title, completed: false} 38 | }) 39 | 40 | on.TOGGLE_TODO((id) => (state) => { 41 | state.Todo[id].completed = !state.Todo[id].completed 42 | }) 43 | 44 | on.REMOVE_TODO((id) => (state) => { 45 | delete state.Todo[id] 46 | }) 47 | 48 | on.REMOVE_ALL(() => (state) => { 49 | state.Todo = {} 50 | }) 51 | 52 | on.CLEAR_COMPLETED(() => (state) => { 53 | state.Query.todos.filter((todo) => todo.completed).forEach((todo) => delete state.Todo[todo.id]) 54 | }) 55 | }) 56 | 57 | store.Todo = reducer({}, (on, effect) => { 58 | on.ADD_TODO((title) => (state) => { 59 | const id = Math.random().toString(36).slice(2) 60 | state.Todo[id] = {id, title, completed: false} 61 | }) 62 | 63 | on.TOGGLE_TODO((id) => (state) => { 64 | state.Todo[id].completed = !state.Todo[id].completed 65 | }) 66 | 67 | on.REMOVE_TODO((id) => (state) => { 68 | delete state.Todo[id] 69 | }) 70 | 71 | on.REMOVE_ALL(() => (state) => { 72 | state.Todo = {} 73 | }) 74 | 75 | on.CLEAR_COMPLETED(() => (state) => { 76 | state.Query.todos.filter((todo) => todo.completed).forEach((todo) => delete state.Todo[todo.id]) 77 | }) 78 | 79 | effect("xxx", (track) => { 80 | // const todo = track(state => state.Todo) 81 | // on.insert() 82 | // on.update() 83 | // on.delete() 84 | }) 85 | 86 | // effect("유저정보가 갱신되면 Account에도 동기화하기", (track) => (state, dispatch) => { 87 | // const accountId = track((state) => state.account?.id) 88 | // const user = track((state) => state.User[accountId]) 89 | // if (!user) { 90 | // return 91 | // } 92 | // 93 | // // runInAction?? 94 | // dispatch((state) => { 95 | // if (!state.account) { 96 | // return 97 | // } 98 | // state.account.display_name = user.display_name 99 | // state.account.avatar_url = user.avatar_url 100 | // state.account.space_id = user.space_id 101 | // }) 102 | // 103 | // // or 104 | // dispatch.계정정보_동기화(user) 105 | // }) 106 | }) 107 | 108 | store.Query.todos = reducer((state) => Object.values(state.Todo).sort((a, b) => a.id.localeCompare(b.id))) 109 | 110 | store.Query.filteredTodos = reducer((state) => state.Query.todos.filter((todo) => todo.completed)) 111 | -------------------------------------------------------------------------------- /src/example/todos.example.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | 3 | interface Todo { 4 | id: number 5 | title: string 6 | completed: boolean 7 | } 8 | 9 | type VisibilityFilter = "SHOW_ALL" | "SHOW_ACTIVE" | "SHOW_COMPLETED" 10 | 11 | interface State { 12 | Query: { 13 | todos: Todo[] 14 | filteredTodos: Todo[] 15 | numRemainingTodos: number 16 | } 17 | Todo: Record 18 | 19 | visibilityFilter: VisibilityFilter 20 | } 21 | 22 | interface Actions { 23 | ADD_TODO(title: string): void 24 | TOGGLE_TODO(id: string): void 25 | REMOVE_TODO(id: string): void 26 | REMOVE_ALL(): void 27 | CLEAR_COMPLETED(): void 28 | } 29 | 30 | const {store, reducer} = createStore() 31 | 32 | store.Todo = reducer({}, (on) => { 33 | on.ADD_TODO((title) => (state) => { 34 | const id = Date.now() 35 | state.Todo[id] = {id, title, completed: false} 36 | }) 37 | 38 | on.TOGGLE_TODO((id) => (state) => { 39 | state.Todo[id].completed = !state.Todo[id].completed 40 | }) 41 | 42 | on.REMOVE_TODO((id) => (state) => { 43 | delete state.Todo[id] 44 | }) 45 | 46 | on.REMOVE_ALL(() => (state) => { 47 | state.Todo = {} 48 | }) 49 | 50 | on.CLEAR_COMPLETED(() => (state) => { 51 | state.Query.todos.filter((todo) => todo.completed).forEach((todo) => delete state.Todo[todo.id]) 52 | }) 53 | }) 54 | 55 | store.Query.todos = reducer((state) => Object.values(state.Todo).sort((a, b) => a.id - b.id)) 56 | 57 | store.Query.filteredTodos = reducer((state) => { 58 | if (state.visibilityFilter === "SHOW_ACTIVE") { 59 | return state.Query.todos.filter((todo) => !todo.completed) 60 | } 61 | 62 | if (state.visibilityFilter === "SHOW_COMPLETED") { 63 | return state.Query.todos.filter((todo) => todo.completed) 64 | } 65 | 66 | return state.Query.todos 67 | }) 68 | 69 | store.Query.numRemainingTodos = reducer((state) => state.Query.todos.filter((todo) => !todo.completed).length) 70 | -------------------------------------------------------------------------------- /src/example/wordle.example.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | 3 | interface Actions { 4 | PUSH_LETTER(key: string): void 5 | BACKSPACE(): void 6 | ENTER(): void 7 | 8 | NOT_IN_WORD_LIST(): void 9 | NOT_IN_WORD_LIST_ANIMATE_END(): void 10 | 11 | MATCH_WORD(inputWord: string): void 12 | START_MATCH_ANIMATE(): void 13 | 14 | NEXT_STEP(): void 15 | GAME_END(): void 16 | 17 | SHOW_TOAST(msg: string, duration?: number): void 18 | } 19 | 20 | type KeyType = "pop" | "absent" | "correct" | "present" 21 | 22 | interface Key { 23 | char: string 24 | type: KeyType 25 | animation: "flip-in" | "flip-out" 26 | } 27 | 28 | type Computed = T 29 | 30 | interface States { 31 | currentState: "IDLE" | "ANIMATING" | "END" 32 | 33 | answer: string 34 | 35 | allLetters: Array> 36 | allLetters_animate: Array<"none" | "shake"> 37 | currentLineIndex: number 38 | 39 | currentLine: Computed> 40 | matchedLetters: Record 41 | 42 | toast: string 43 | } 44 | 45 | const {store, reducer, dispatch} = createStore() 46 | 47 | const KEY1 = "qwertyuiop".split("") 48 | const KEY2 = "asdfghjkl".split("") 49 | const KEY3 = "zxcvbnm".split("") 50 | 51 | const NUM_MAX_WORD_COUNT = 5 52 | const NUM_TRY_COUNT = 6 53 | 54 | const WORDS = ["world"] 55 | 56 | store.currentState = reducer("IDLE", (on) => { 57 | on.MATCH_WORD(() => (state) => { 58 | state.currentState = "ANIMATING" 59 | }) 60 | 61 | on.NEXT_STEP(() => (state) => { 62 | state.currentState = "IDLE" 63 | }) 64 | 65 | on.GAME_END(() => (state) => { 66 | state.currentState = "END" 67 | }) 68 | }) 69 | 70 | // const answer = WORDS[Math.floor(Math.random() * WORDS.length)] 71 | store.answer = "world" 72 | 73 | store.currentLineIndex = reducer(0, (on) => { 74 | on.NEXT_STEP(() => (state) => { 75 | state.currentLineIndex++ 76 | }) 77 | }) 78 | 79 | store.allLetters = reducer( 80 | Array(NUM_TRY_COUNT) 81 | .fill("") 82 | .map(() => []) 83 | ) 84 | 85 | store.currentLine = reducer( 86 | (state) => state.allLetters[state.currentLineIndex], 87 | (on) => { 88 | const matchWordle = (s_answer: string, s_guess: string) => { 89 | const answer = s_answer.split("") 90 | const guess = s_guess.split("") 91 | 92 | const result = guess.map((char) => ({char, type: "absent"})) 93 | 94 | // correct 95 | guess.forEach((char, i) => { 96 | if (char === answer[i]) { 97 | result[i] = {char, type: "correct"} 98 | answer[i] = "" 99 | } 100 | }) 101 | 102 | // present 103 | guess.forEach((char, i) => { 104 | if (result[i].type === "correct") return 105 | if (answer.includes(char)) { 106 | result[i] = {char, type: "present"} 107 | answer[answer.indexOf(char)] = "" 108 | } 109 | }) 110 | 111 | return result 112 | } 113 | 114 | on.PUSH_LETTER((char) => (state) => { 115 | if (state.currentLine.length >= NUM_MAX_WORD_COUNT) { 116 | return 117 | } 118 | 119 | state.currentLine.push({char, type: "pop", animation: ""}) 120 | }) 121 | 122 | on.BACKSPACE(() => (state) => { 123 | state.currentLine.pop() 124 | }) 125 | 126 | on.ENTER(() => (state) => { 127 | if (state.currentLine.length < NUM_MAX_WORD_COUNT) { 128 | return 129 | } 130 | 131 | const inputWord = state.currentLine.map((key) => key.char).join("") 132 | 133 | // check Not in word list 134 | if (!WORDS.includes(inputWord)) { 135 | dispatch.NOT_IN_WORD_LIST() 136 | return 137 | } 138 | 139 | dispatch.MATCH_WORD(inputWord) 140 | }) 141 | 142 | on.MATCH_WORD((inputWord) => (state) => { 143 | // match Animation 144 | // @TODO: animation with 비동기... 비동기를 어떻게 할까? 145 | const matched = matchWordle(inputWord, state.answer) 146 | 147 | // filp-in, flip-out animation 적용 148 | matched.forEach(({char, type}, i) => { 149 | setTimeout(async () => { 150 | state.currentLine[i] = {char, type: "pop", animation: "flip-in"} 151 | setTimeout(() => (state.currentLine[i] = {char, type, animation: "flip-out"}), 250) 152 | }, 250 * i) 153 | }) 154 | 155 | setTimeout( 156 | () => { 157 | // 매칭된 글자를 저장한다. 158 | state.currentLine.forEach(({char, type}) => { 159 | if (state.matchedLetters[char] === "correct") { 160 | return 161 | } 162 | state.matchedLetters[char] = type 163 | }) 164 | 165 | // 다음 단계로 이동 166 | if (currentStep >= NUM_MAX_WORD_COUNT) { 167 | dispatch.GAME_END() 168 | return 169 | } 170 | 171 | dispatch.NEXT_STEP() 172 | }, 173 | 250 * matched.length + 250 174 | ) 175 | }) 176 | } 177 | ) 178 | 179 | store.allLetters_animate = reducer(Array(NUM_TRY_COUNT).fill("none"), (on) => { 180 | on.NOT_IN_WORD_LIST(() => (state) => { 181 | state.allLetters_animate[state.currentLineIndex] = "shake" 182 | }) 183 | 184 | on.NOT_IN_WORD_LIST_ANIMATE_END(() => (state) => { 185 | state.allLetters_animate[state.currentLineIndex] = "none" 186 | }) 187 | }) 188 | 189 | // 토스트 팝업 190 | store.toast = reducer("", (on) => { 191 | const TOAST_DURATION = 1500 192 | const TOAST_DURATION_LONG = 5000 193 | 194 | const delay = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) 195 | 196 | on.SHOW_TOAST((msg, duration = TOAST_DURATION) => async (state) => { 197 | state.toast = msg 198 | await delay(duration) 199 | state.toast = "" 200 | }) 201 | 202 | on.NOT_IN_WORD_LIST(() => () => { 203 | dispatch.SHOW_TOAST("Not In word list") 204 | }) 205 | 206 | on.GAME_END(() => (state) => { 207 | dispatch.SHOW_TOAST("정답은 " + state.answer + " 입니다.", TOAST_DURATION_LONG) 208 | }) 209 | }) 210 | -------------------------------------------------------------------------------- /src/example/wordle.no-example.ts: -------------------------------------------------------------------------------- 1 | const KEY1 = "qwertyuiop".split("") 2 | const KEY2 = "asdfghjkl".split("") 3 | const KEY3 = "zxcvbnm".split("") 4 | 5 | const NUM_MAX_WORD_COUNT = 5 6 | const NUM_TRY_COUNT = 6 7 | 8 | const WORDS = ["world"] 9 | 10 | enum State { 11 | IDLE, 12 | IS_ANIMATING, 13 | GAME_END, 14 | } 15 | 16 | let currentState:State = State.IDLE 17 | 18 | const answer = "world" 19 | // const answer = WORDS[Math.floor(Math.random() * WORDS.length)] 20 | 21 | const allLetters = Array(NUM_TRY_COUNT).fill(null).map(() => []) 22 | const allLetters_animate = Array(NUM_TRY_COUNT).fill("") 23 | const matchedLetters = Object.create(null) 24 | 25 | let currentStep = 0 26 | 27 | /// 28 | const getCurrentLetters = () => allLetters[currentStep] 29 | 30 | const matchWordle = (s_answer:string, s_guess:string) => { 31 | const answer = s_answer.split("") 32 | const guess = s_guess.split("") 33 | 34 | const result = guess.map(char => ({char, type: "absent"})) 35 | 36 | // correct 37 | guess.forEach((char, i) => { 38 | if (char === answer[i]) { 39 | result[i] = {char, type: "correct"} 40 | answer[i] = "" 41 | } 42 | }) 43 | 44 | // present 45 | guess.forEach((char, i) => { 46 | if (result[i].type === "correct") return 47 | if (answer.includes(char)) { 48 | result[i] = {char, type: "present"} 49 | answer[answer.indexOf(char)] = "" 50 | } 51 | }) 52 | 53 | return result 54 | } 55 | 56 | // 토스트 팝업 57 | const TOAST_DURATION = 1500 58 | const TOAST_DURATION_LONG = 5000 59 | 60 | let toast = "" 61 | let timer:ReturnType 62 | 63 | const showToast = (text:string, duration = TOAST_DURATION) => { 64 | toast = text 65 | clearTimeout(timer) 66 | timer = setTimeout(() => { 67 | toast = "" 68 | }, duration) 69 | } 70 | 71 | // 글자 입력 72 | const pushLetter = (char:string) => { 73 | if (currentState !== State.IDLE) return 74 | 75 | const letters = getCurrentLetters() 76 | if (letters.length >= NUM_MAX_WORD_COUNT) { 77 | return 78 | } 79 | 80 | allLetters[currentStep] = [...letters, {char, type: "", animation: "pop"}] 81 | } 82 | 83 | // 백스페이스: 글자삭제 84 | const backspace = () => { 85 | if (currentState !== State.IDLE) return 86 | 87 | const letters = getCurrentLetters() 88 | allLetters[currentStep] = letters.slice(0, -1) 89 | } 90 | 91 | // 엔터 92 | const enter = () => { 93 | if (currentState !== State.IDLE) return 94 | 95 | const letters = getCurrentLetters() 96 | if (letters.length < NUM_MAX_WORD_COUNT) { 97 | return 98 | } 99 | 100 | const input = letters.map(({char}) => char).join("") 101 | 102 | // check Not in word list 103 | if (!WORDS.includes(input)) { 104 | showToast("Not In word list") 105 | allLetters_animate[currentStep] = "shake" 106 | return 107 | } 108 | 109 | // 입력된 글자를 통해 단어를 찾는다. 110 | const matched = matchWordle(answer, input) 111 | 112 | // filp-in, flip-out animation 적용 113 | currentState = State.IS_ANIMATING 114 | matched.forEach(({char, type}, i) => { 115 | const step = currentStep 116 | setTimeout(async () => { 117 | allLetters[step][i] = {char, type: "pop", animation: "flip-in"} 118 | await tick() 119 | setTimeout(() => allLetters[step][i] = {char, type, animation: "flip-out"}, 250) 120 | }, 250 * i) 121 | }) 122 | 123 | setTimeout(() => { 124 | // 매칭된 글자를 저장한다. 125 | allLetters[currentStep].forEach(({char, type}) => { 126 | if (matchedLetters[char] === "correct") { 127 | return 128 | } 129 | matchedLetters[char] = type 130 | }) 131 | 132 | // 다음 단계로 이동 133 | if (currentStep >= NUM_MAX_WORD_COUNT) { 134 | setTimeout(() => end()) 135 | } 136 | 137 | currentStep++ 138 | currentState = State.IDLE 139 | 140 | }, 250 * matched.length + 250) 141 | } 142 | 143 | const end = () => { 144 | currentState = State.GAME_END 145 | showToast("정답은 " + answer + " 입니다.", TOAST_DURATION_LONG) 146 | } 147 | 148 | 149 | // 키를 누르면 키입력 전달 150 | const onkeydown = (event) => { 151 | if (currentState !== State.IDLE) return 152 | 153 | if (event.metaKey || event.ctrlKey || event.altKey) { 154 | return 155 | } 156 | 157 | if (event.key.length === 1 && event.key.match(/[a-z]/i)) { 158 | pushLetter(event.key) 159 | } 160 | else if (event.key === "Backspace") { 161 | backspace() 162 | } 163 | else if (event.key === "Enter") { 164 | enter() 165 | } 166 | } 167 | 168 | 169 | 170 | //
    171 | // 172 | //
    173 | // 174 | // 175 | //
    176 | //
    177 | // {#each allLetters as row, step} 178 | //
    allLetters_animate[step]=''}> 179 | // {#each Array(5) as _, index} 180 | //
    {row[index]?.chars ?? ''}
    186 | // {/each} 187 | //
    188 | // {/each} 189 | //
    190 | //
    191 | // 192 | // 193 | //
    194 | // {#each KEY1 as key} 195 | // pushLetter(key)} type={matchedLetters[key]}>{key} 196 | // {/each} 197 | // 198 | //
    199 | // {#each KEY2 as key} 200 | // pushLetter(key)} type={matchedLetters[key]}>{key} 201 | // {/each} 202 | //
    203 | // 204 | // ENTER 205 | // {#each KEY3 as key} 206 | // pushLetter(key)} type={matchedLetters[key]}>{key} 207 | // {/each} 208 | // 209 | // 210 | // 211 | // 212 | // 213 | //
    214 | // 215 | // {#if toast} 216 | //
    {toast}
    217 | // {/if} 218 | //
    219 | // 220 | // 221 | // 테오의 프론트엔드 - Wordle Challenge 222 | // 223 | // 224 | // 225 | // 226 | > -------------------------------------------------------------------------------- /src/libs/api/adapter/axiosAdapter.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import {createAPIAdapter} from "../apiForge.ts" 3 | 4 | export const axiosAdapter = createAPIAdapter((config, method, url, data) => { 5 | return axios({ 6 | url, 7 | method, 8 | data 9 | }) 10 | }) -------------------------------------------------------------------------------- /src/libs/api/adapter/fetchAdapter.ts: -------------------------------------------------------------------------------- 1 | import {createAPIAdapter} from "../apiForge.ts" 2 | 3 | export class HttpError extends Error { 4 | constructor(public status:number, public statusText:string, public body:unknown) { 5 | super(`HTTP Error ${status}: ${statusText} ${JSON.stringify(body)}`) 6 | } 7 | } 8 | 9 | export const fetchAdapter = () => createAPIAdapter(async (config, method, url, data) => { 10 | 11 | // bodyParser 12 | let body = null 13 | if (data) { 14 | if (config.headers["Content-Type"] === "multipart/form-data") { 15 | body = new FormData() 16 | for (const key in data) { 17 | body.append(key, data[key]) 18 | } 19 | } 20 | else { 21 | body = JSON.stringify(data) 22 | } 23 | } 24 | 25 | // Timeout 처리 26 | const abortController = new AbortController() 27 | const timeoutId = config.timeout > 0 ? setTimeout(() => abortController.abort(), config.timeout) : null 28 | 29 | try { 30 | const request = { 31 | 32 | // @TODO: extra config!! 33 | 34 | // ...config.fetchOptions, 35 | method, 36 | headers: {...config.headers}, 37 | signal: abortController.signal, 38 | body, 39 | } 40 | 41 | const response = await fetch(url, request) 42 | clearTimeout(timeoutId) 43 | 44 | // response 자동추론 45 | let responseData 46 | 47 | const contentType = response.headers.get("Content-Type") 48 | if (contentType && contentType.includes("application/json")) { 49 | responseData = await response.json() 50 | } 51 | else { 52 | responseData = await response.text() 53 | } 54 | 55 | if (!response.ok) { 56 | throw new HttpError(response.status, response.statusText, responseData) 57 | } 58 | 59 | // @FIXME: 로깅 추가 60 | console.log(`API Call: ${method} ${url} - Status: ${response.status}`) 61 | 62 | return { 63 | ...response, 64 | data: responseData, 65 | config: config, 66 | request: {url, ...request} 67 | } 68 | } 69 | catch (error) { 70 | clearTimeout(timeoutId) 71 | if (error.name === "AbortError") { 72 | throw new Error("Request timed out") 73 | } 74 | else { 75 | throw error 76 | } 77 | } 78 | }) -------------------------------------------------------------------------------- /src/libs/api/api.example.ts: -------------------------------------------------------------------------------- 1 | import {createAPI, createResponse} from "./apiForge" 2 | import {fetchAdapter} from "./adapter/fetchAdapter" 3 | 4 | type Response = createResponse<{ 5 | status: number 6 | data: T 7 | }> 8 | 9 | interface Calendar { 10 | id: string 11 | name: string 12 | } 13 | 14 | interface Post { 15 | id: string 16 | } 17 | 18 | interface Comment { 19 | id: string 20 | } 21 | 22 | export interface API_Post { 23 | GET: { 24 | ["/posts/recommend"](query: {lastKey: number}): Response<{lastKey: number; list: Post[]}> 25 | ["/posts/:postId"](postId: string): Response 26 | ["/posts/:postId/comments"](postId: string, params?: unknown): Response 27 | } 28 | } 29 | 30 | export interface API_Calendar { 31 | GET: { 32 | ["/calendars"](): Response 33 | ["/calendars/:calendarId"](calendarId: string): Response 34 | } 35 | 36 | POST: { 37 | ["/calendars/:calendarId"](calendarId: string, body: Calendar, q: {lastKey: string}): Response 38 | } 39 | 40 | PUT: { 41 | ["/calendars/:calendarId"](): Response 42 | } 43 | } 44 | 45 | type API = API_Post & API_Calendar 46 | 47 | export const api = createAPI({ 48 | baseURL: "https://uc-api.ep.oror.io/api", 49 | adapter: fetchAdapter(), 50 | }) 51 | -------------------------------------------------------------------------------- /src/libs/api/api.example2.ts: -------------------------------------------------------------------------------- 1 | import {createResponse} from "./apiForge.ts" 2 | 3 | interface Post { 4 | id:string 5 | title:string 6 | comments:Comment[] 7 | } 8 | 9 | interface Comment { 10 | id:string 11 | } 12 | 13 | interface Calendar { 14 | id:string 15 | name:string 16 | } 17 | 18 | type Response = createResponse<{ 19 | status:number, 20 | data:T 21 | }> 22 | 23 | export interface API_Calendar { 24 | GET:{ 25 | ["/calendars"]():Response 26 | ["/calendars/:calendarId"](calendarId:string):Response 27 | } 28 | 29 | POST:{ 30 | ["/calendars"](calendarId:string, body:Calendar, query:{lastKey:string}):Response 31 | } 32 | 33 | PUT:{ 34 | ["/calendars/:calendarId"]():Response 35 | } 36 | 37 | DELETE:{ 38 | ["/calendars/:calendarId"]():Response 39 | } 40 | } 41 | 42 | type API = API_Calendar 43 | 44 | const api = {} as API_Calendar 45 | 46 | const useREST = (fn:(api:API) => T) => { 47 | const status = "idle" 48 | const data = {} as T 49 | const isFetching = false 50 | const isLoading = false 51 | const isError = false 52 | 53 | const $ = {} as T 54 | const invalidate = () => {} 55 | 56 | const refetch = () => {} 57 | 58 | const fns = fn(api) 59 | 60 | return { 61 | status, 62 | data, 63 | isFetching, 64 | isLoading, 65 | isError, 66 | 67 | invalidate, 68 | refetch, 69 | 70 | ...fns, 71 | } 72 | } 73 | 74 | const params = {} 75 | 76 | 77 | const useQuery = (fn:Function) => fn 78 | 79 | const useCalendars = () => useREST( api => { 80 | 81 | return { 82 | FETCH: useQuery(api.GET["/calendars"]), 83 | RECOMMENDS: api.GET["/calendars"], 84 | GET: api.GET["/calendars/:calendarId"], 85 | 86 | POST: api.POST["/calendars"], 87 | DELETE: api.DELETE["/calendars/:id"], 88 | TALK: api.POST["/calendars/:id/talk"] 89 | } 90 | }) 91 | 92 | const calendars$ = useCalendars() 93 | 94 | const id = "111312321" 95 | 96 | // const calendars = calendars$.GET(id) 97 | // const recommends = calendars$.RECOMMENDS() 98 | // 99 | // calendars.isFetching 100 | // calendars.isLoading 101 | // calendars.isError 102 | // calendars.status 103 | // calendars.data 104 | // 105 | // calendars$.invalidate() 106 | // calendars$.refetch() 107 | 108 | const handleTest = () => { 109 | const params = {id, name} 110 | 111 | calendars$.POST(id, params, (err, res) => { 112 | if (err) { 113 | /// @TODO: reset 114 | } 115 | // calendars$[id].set(params) 116 | calendars$.invalidate() 117 | }) 118 | } 119 | 120 | // createSelector(state) 121 | // calendars$.DELETE(postId) 122 | 123 | 124 | const id 125 | const title 126 | 127 | const createNewPost = () => { 128 | calendars.POST({id, title}) 129 | } 130 | 131 | const updatePost = () => { 132 | calendars.$[103020].PUT({title}) 133 | } 134 | 135 | 136 | const recommend = useREST(api => api.calendars.recommend) -------------------------------------------------------------------------------- /src/libs/api/apiForge.ts: -------------------------------------------------------------------------------- 1 | export interface HttpConfig { 2 | baseURL:string 3 | headers:Record 4 | timeout:number 5 | config:Record 6 | adapter:(config:Record, method:string, path:string, argumentsList:unknown[]) => void 7 | } 8 | 9 | const createAPIPathProxy = (config:HttpConfig, method:string, path:string):T => new Proxy(Function, { 10 | get(_, prop:string) { 11 | if (prop === "toString") return () => path 12 | return createAPIPathProxy(config, method, path + prop) 13 | }, 14 | 15 | async apply(_, __, argumentsList) { 16 | 17 | // 동적 경로 파라미터를 처리하기 위한 정규 표현식 18 | const dynamicPathRegex = /:[^/?]+|\([^)]+\)|\{[^)]+}|\[[^]+]|<[^>]+>/g 19 | 20 | // 경로에 있는 동적 파라미터 (:param)를 인자 값으로 치환 21 | const resolvedPath = path.replace(dynamicPathRegex, (paramName) => { 22 | const value = argumentsList.shift() 23 | 24 | if (value === undefined) { 25 | throw new Error(`Missing value for path parameter '${paramName}'`) 26 | } 27 | if (typeof value !== "number" && typeof value !== "string") { 28 | throw new Error(`Parameter type must be string or number '${paramName}'`) 29 | } 30 | return encodeURIComponent(value) 31 | }) 32 | 33 | // baseURL과 resolvedPath 사이의 슬래시 처리 34 | const hasTrailingSlash = config.baseURL.endsWith("/") 35 | const hasLeadingSlash = resolvedPath.startsWith("/") 36 | let url = config.baseURL 37 | 38 | // 둘 다 슬래시가 없으면 추가 39 | if (!hasTrailingSlash && !hasLeadingSlash) { 40 | url += "/" 41 | } 42 | // 둘 다 슬래시가 있으면 하나 제거 43 | else if (hasTrailingSlash && hasLeadingSlash) { 44 | url = url.slice(0, -1) 45 | } 46 | 47 | url += resolvedPath 48 | 49 | // body와 queryString 50 | let body = null 51 | let queryString = null 52 | 53 | // GET 요청의 경우, params를 쿼리 스트링으로 변환 54 | if (method !== "GET" && method !== "HEAD") { 55 | body = argumentsList.shift() 56 | } 57 | 58 | const params = Object.assign(Object.create(null), ...argumentsList) 59 | queryString = new URLSearchParams(params).toString() 60 | 61 | // URL에 이미 쿼리 스트링이 있으면 '&'를 사용, 없으면 '?'를 사용 62 | if (queryString) { 63 | url += (url.includes("?") ? "&" : "?") + queryString 64 | } 65 | 66 | return config.adapter(config, method, url, body) 67 | } 68 | }) as T 69 | 70 | const defaultConfig:HttpConfig = { 71 | baseURL: "/", 72 | headers: {"Content-Type": "application/json"}, 73 | timeout: 0, 74 | config: {} 75 | } 76 | 77 | export const createAPI = (config:Partial):T => new Proxy(Object.create(null), { 78 | get: (_, method:string) => createAPIPathProxy({...defaultConfig, ...config}, method.toUpperCase(), "") 79 | }) 80 | 81 | export const createAPIAdapter = (fn:(config:HttpConfig, method:string, url:string, data:unknown) => Promise) => { 82 | return fn 83 | } 84 | 85 | interface APIResponse extends Response { 86 | data:T 87 | config:HttpConfig 88 | request:RequestInit 89 | } 90 | 91 | export type createResponse = Promise> -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | import App from "./App.tsx" 4 | import "@adorable.css" 5 | import {BrowserRouter} from "react-router-dom" 6 | 7 | ReactDOM.createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | -------------------------------------------------------------------------------- /src/routes/Counter.tsx: -------------------------------------------------------------------------------- 1 | import {createStore} from "../test/newStore.ts" 2 | import {memo} from "react" 3 | 4 | interface State { 5 | count: number 6 | doubledCount: number 7 | onlyUp: number 8 | noDeps: number 9 | } 10 | 11 | interface Actions { 12 | INCREASE(by: number): void 13 | DECREASE(by: number): void 14 | RESET(): void 15 | } 16 | 17 | const useStore = createStore(({store, reducer}) => { 18 | // 1 19 | store.count = reducer(0, (on) => { 20 | on.INCREASE((by) => (state) => (state.count += by)) 21 | on.DECREASE((by) => (state) => (state.count -= by)) 22 | on.RESET(() => (state) => (state.count = 0)) 23 | }) 24 | 25 | store.onlyUp = reducer(0, (on) => { 26 | on.INCREASE((by) => (state) => (state.onlyUp += by)) 27 | }) 28 | 29 | store.noDeps = reducer(0, (on) => { 30 | on.INCREASE((by) => (state) => (state.noDeps += by)) 31 | on.DECREASE((by) => (state) => (state.noDeps -= by)) 32 | on.RESET(() => (state) => (state.noDeps = 0)) 33 | }) 34 | 35 | store.doubledCount = reducer((state) => state.count * 2) 36 | }) 37 | 38 | function Counter() { 39 | console.log("Counter1: re-render") 40 | 41 | const {dispatch, count, doubledCount, version} = useStore("counter") 42 | 43 | const 증가 = () => dispatch.INCREASE(1) 44 | 45 | const 감소 = () => dispatch.DECREASE(1) 46 | 47 | const 초기화 = () => dispatch.RESET() 48 | 49 | return ( 50 | <> 51 |
    52 | 55 | 56 | 57 | 58 | 59 |
    60 | 61 | 62 | 63 | ) 64 | } 65 | 66 | const CounterSubComponent = memo(() => { 67 | console.log("CounterSubComponent: re-render") 68 | 69 | const {onlyUp, dispatch, version} = useStore("counter") 70 | 71 | const 증가 = () => dispatch.INCREASE(1) 72 | 73 | const 감소 = () => dispatch.DECREASE(1) 74 | 75 | return ( 76 | <> 77 |
    78 | 81 | 82 | 83 |
    84 | 85 | ) 86 | }) 87 | 88 | export default function CounterApp() { 89 | console.log("CounterApp: re-render") 90 | 91 | return ( 92 |
    93 | 94 |
    95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/routes/CounterMulti.tsx: -------------------------------------------------------------------------------- 1 | import {createComponentStore} from "../test/newStore.ts" 2 | 3 | interface CounterState { 4 | count: number 5 | doubledCount: number 6 | } 7 | 8 | interface CounterActions { 9 | INCREASE(by: number): void 10 | DECREASE(by: number): void 11 | RESET(): void 12 | } 13 | 14 | const [useCounterComponentStore, CounterProvider] = createComponentStore(({store, reducer}) => { 15 | store.count = reducer(0, (on) => { 16 | on.INCREASE((by) => (state) => (state.count += by)) 17 | on.DECREASE((by) => (state) => (state.count -= by)) 18 | on.RESET(() => (state) => (state.count = 0)) 19 | }) 20 | 21 | store.doubledCount = reducer((state) => state.count * 2) 22 | }) 23 | 24 | function Counter() { 25 | console.log("Counter1: re-render") 26 | 27 | const {count, doubledCount} = useCounterComponentStore() 28 | 29 | return ( 30 | <> 31 |
    32 |
    count is {count}
    33 |
    doubledCount is {doubledCount}
    34 |
    35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | function CounterControl() { 42 | console.log("Counter1: re-render") 43 | 44 | const {dispatch} = useCounterComponentStore() 45 | 46 | const 증가 = () => dispatch.INCREASE(1) 47 | 48 | const 감소 = () => dispatch.DECREASE(1) 49 | 50 | const 초기화 = () => dispatch.RESET() 51 | 52 | return ( 53 | <> 54 |
    55 | 56 | 57 | 58 |
    59 | 60 | ) 61 | } 62 | 63 | export default function CounterStoreApp() { 64 | console.log("CounterApp: re-render") 65 | 66 | return ( 67 |
    68 |

    ComponentStore

    69 | 70 | 71 | 72 | 73 |
    74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
    84 | 85 | 86 | 87 | 88 |
    89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/routes/TodoList.tsx: -------------------------------------------------------------------------------- 1 | import {createComponentStore, createStore} from "../test/newStore.ts" 2 | 3 | // 4 | // User 5 | // ----------------------------------------------------------------------------------------------------------------- 6 | const account = {id: "accountId"} 7 | 8 | interface User { 9 | id: string 10 | name: string 11 | } 12 | 13 | const UserRepo = {} as Record 14 | 15 | // 16 | // Todo 17 | // ----------------------------------------------------------------------------------------------------------------- 18 | interface Todo { 19 | id: string 20 | text: string 21 | completed: boolean 22 | creatorId: string 23 | } 24 | 25 | interface TodoExtra { 26 | creator?: User 27 | 수정권한이_있는가: false 28 | } 29 | 30 | interface TodoActions { 31 | TOGGLE(): void 32 | SET_TEXT(text: string): void 33 | } 34 | 35 | export const [useTodo, TodoProvider, TodoRepo] = createComponentStore(({store: Todo, reducer, key}) => { 36 | // Todo.id = key 37 | 38 | Todo.text = reducer("", (on) => { 39 | on.SET_TEXT((text) => (state) => (state.text = text)) 40 | }) 41 | 42 | Todo.completed = reducer(false, (on) => { 43 | on.TOGGLE(() => (state) => (state.completed = !state.completed)) 44 | }) 45 | 46 | // extra Example 47 | Todo.creator = reducer((state) => UserRepo[state.creatorId]) 48 | Todo.수정권한이_있는가 = reducer((state) => state.creatorId === account.id) 49 | }) 50 | 51 | // 52 | // Todo List 53 | // ----------------------------------------------------------------------------------------------------------------- 54 | 55 | interface TodoApp { 56 | Todo: Record 57 | 58 | todos: Todo[] 59 | num_todos: number 60 | num_completed_todos: number 61 | } 62 | 63 | interface TodoAppActions { 64 | ADD_TODO(id: string, text: string): void 65 | REMOVE_TODO(id: string): void 66 | } 67 | 68 | export const useTodoApp = createStore(({store, reducer}) => { 69 | // Repository 70 | store.Todo = reducer(TodoRepo, (on) => { 71 | on.ADD_TODO((id, text) => (state) => { 72 | state.Todo[id] = {id, text, completed: false, creatorId: "tmp"} 73 | }) 74 | 75 | on.REMOVE_TODO((id) => (state) => { 76 | delete state.Todo[id] 77 | }) 78 | }) 79 | 80 | // computed value 81 | store.todos = reducer((state) => Object.values(state.Todo).filter(Boolean)) 82 | 83 | store.num_todos = reducer((state) => state.todos.length) 84 | 85 | store.num_completed_todos = reducer((state) => state.todos.filter((todo) => todo.completed).length) 86 | 87 | // effect 88 | // when(() => { 89 | 90 | // }, cond)) 91 | 92 | // when(cond, () => { 93 | 94 | // }) 95 | 96 | // effect("xxxx", (state) => { 97 | // 98 | // }) 99 | 100 | // middleware 101 | // store.dispatch.ADD_TODO = (id, text) => [id, text, account.id] 102 | }) 103 | 104 | // 105 | // 106 | // React 107 | // ----------------------------------------------------------------------------------------------------------------- 108 | function TodoItem() { 109 | const {id, text, completed, dispatch} = useTodo() 110 | const app = useTodoApp() 111 | 112 | const toggleTodo = () => dispatch.TOGGLE() 113 | const removeTodo = () => app.dispatch.REMOVE_TODO(id) 114 | 115 | return ( 116 |
  • 117 |
    118 | {id} - {text} 119 |
    120 | 121 |
  • 122 | ) 123 | } 124 | 125 | export default function TodoList() { 126 | const {todos, num_todos, dispatch} = useTodoApp() 127 | 128 | const generateUniqueId = () => Math.random().toString(36).slice(2) 129 | 130 | const addTodo = (e: React.KeyboardEvent) => { 131 | if (e.nativeEvent.isComposing) return 132 | if (e.key === "Enter") { 133 | const text = e.currentTarget.value 134 | const newId = generateUniqueId() 135 | dispatch.ADD_TODO(newId, text) 136 | 137 | e.currentTarget.value = "" 138 | } 139 | } 140 | 141 | return ( 142 | <> 143 |
    num_todos: {num_todos}
    144 | 145 |
      146 | {todos.map((todo) => ( 147 | // extra value들도 넘길수 있으면 좋겠다. index같은... 148 | 149 | 150 | 151 | ))} 152 |
    153 | 154 | ) 155 | } 156 | -------------------------------------------------------------------------------- /src/routes/Wordle.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react" 2 | import {createComponentStore, createStore} from "../test/newStore.ts" 3 | import {delay, map, of} from "rxjs" 4 | 5 | interface Char { 6 | type: "idle" | "correct" | "present" | "absent" 7 | char: string 8 | animation: "" | "flip-in" | "flip-out" 9 | } 10 | 11 | interface Line { 12 | chars: Char[] 13 | index: number 14 | } 15 | 16 | interface LineActions { 17 | PUSH_KEY(key: string): void 18 | BACKSPACE(): void 19 | 20 | SHOW_NOT_IN_WORD_LIST_ANIMATION(): void 21 | NOT_IN_WORD_LIST_ANIMATE_END(): void 22 | SHOW_MATCHED_ANIMATION(matched: Char[]): void 23 | } 24 | 25 | const [useLine, LineProvider, LineRepo] = createComponentStore(({store, reducer, dispatch, middleware}) => { 26 | const update = reducer 27 | 28 | const createChar = (char: string) => ({type: "idle", char, animation: ""}) as Char 29 | 30 | // 이게 제일 좋을까? 의존성... 31 | store.index = 0 32 | store.chars = ["", "", "", "", ""].map(createChar) 33 | 34 | store.update = update(null, (on) => { 35 | on.PUSH_KEY((key) => (state) => { 36 | if (state.index >= 5) return 37 | state.chars[state.index].char = key 38 | state.index++ 39 | }) 40 | 41 | on.BACKSPACE(() => (state) => { 42 | if (state.index <= 0) return 43 | state.chars[state.index - 1].char = "" 44 | state.index-- 45 | }) 46 | }) 47 | 48 | store.effect = update(null, (on) => { 49 | on.SHOW_MATCHED_ANIMATION((matched) => async (state) => { 50 | // filp-in, flip-out animation 적용 51 | matched.forEach(({char, type}, i) => { 52 | state.chars[i] = delay(250 * i, {char, type: "pop", animation: "flip-in"}) 53 | state.chars[i] = queue({char, type: type, animation: "flip-out"}) 54 | }) 55 | await Promise.all(state.chars) 56 | }) 57 | }) 58 | }, "Line") 59 | 60 | interface AppState { 61 | Line: typeof LineRepo 62 | 63 | answer: string 64 | currentLineIndex: number 65 | matchedLetters: Record 66 | 67 | readonly currentLine: (typeof LineRepo)[keyof typeof LineRepo] 68 | readonly inputWord: string 69 | readonly canEnter: boolean 70 | 71 | toast: string 72 | } 73 | 74 | interface AppActions { 75 | PUSH_KEY(key: string): void 76 | BACKSPACE(): void 77 | ENTER(): void 78 | 79 | NOT_IN_WORD_LIST(): void 80 | 81 | SHOW_MATCHED_ANIMATION(matched: Char[]): void 82 | SAVE_MATCHED_LETTER(matched: Char[]): void 83 | 84 | NEXT_STEP(): void 85 | GAME_END(): void 86 | 87 | // effect 88 | MATCH_WORD(inputWord: string, answer: string, currentLineIndex: number): void 89 | 90 | // 91 | SHOW_TOAST(msg: string, duration?: number): void 92 | } 93 | 94 | const useStore = createStore(({store, reducer}) => { 95 | const WORDS = ["DRIVE", "XXXXX"] 96 | const NUM_MAX_WORD_COUNT = 6 97 | 98 | const selector = reducer 99 | const update = reducer 100 | 101 | store.answer = "REACT" 102 | 103 | store.Line = LineRepo 104 | 105 | store.currentLineIndex = reducer(0, (on) => { 106 | on.NEXT_STEP(() => (state) => state.currentLineIndex++) 107 | }) 108 | 109 | store.currentLine = selector((state) => state.Line.of(state.currentLineIndex)) 110 | 111 | store.matchedLetters = reducer({}, (on) => { 112 | on.SAVE_MATCHED_LETTER(() => (state) => { 113 | state.currentLine.chars.forEach(({char, type}) => { 114 | if (state.matchedLetters[char] !== "correct") { 115 | state.matchedLetters[char] = type 116 | } 117 | }) 118 | }) 119 | }) 120 | 121 | store.inputWord = selector((state) => state.currentLine.chars.join("")) 122 | 123 | store.canEnter = selector((state) => state.inputWord.length === 5) 124 | 125 | // 토스트 팝업 126 | store.toast = reducer("", (on) => { 127 | const TOAST_DURATION = 1500 128 | const TOAST_DURATION_LONG = 5000 129 | 130 | on.SHOW_TOAST((msg, duration = TOAST_DURATION) => (state) => { 131 | return of(msg).pipe( 132 | delay(duration), 133 | map(() => "") 134 | ) 135 | }) 136 | }) 137 | 138 | // 139 | const isNotInWordList = (inputWord: string) => !WORDS.includes(inputWord) 140 | 141 | const matchWordle = (input: string, answer: string) => { 142 | return [] as Char[] 143 | } 144 | 145 | store.update = update(null, (on) => { 146 | on.PUSH_KEY((key) => (state) => { 147 | state.currentLine.dispatch.PUSH_KEY(key) 148 | }) 149 | 150 | on.BACKSPACE(() => (state) => { 151 | state.currentLine.dispatch.BACKSPACE() 152 | }) 153 | 154 | on.ENTER(() => (state, effect) => { 155 | if (!state.canEnter) { 156 | return 157 | } 158 | 159 | effect.MATCH_WORD(state.inputWord, state.answer) 160 | }) 161 | 162 | on.MATCH_WORD((inputWord: string, answer: string, currentLineIndex: number) => async (_, dispatch) => { 163 | // Word가 아닌 경우, 164 | if (isNotInWordList(inputWord)) { 165 | dispatch.NOT_IN_WORD_LIST() 166 | return 167 | } 168 | 169 | // match Animation 170 | const matched = matchWordle(inputWord, answer) 171 | 172 | let somthing = await animation() 173 | dispatch.SHOW_MATCHED_ANIMATION(matched) 174 | 175 | // 매칭된 글자를 저장한다. 176 | dispatch.SAVE_MATCHED_LETTER(matched) 177 | 178 | // 도전 기회가 남았다면? -> 다음 기회 179 | if (currentLineIndex < NUM_MAX_WORD_COUNT) { 180 | dispatch.NEXT_STEP() 181 | return 182 | } 183 | 184 | // 게임 끝 185 | dispatch.GAME_END() 186 | }) 187 | 188 | // 189 | on.NOT_IN_WORD_LIST(() => (state) => { 190 | state.currentLine.dispatch.SHOW_NOT_IN_WORD_LIST_ANIMATION() 191 | }) 192 | 193 | on.SHOW_MATCHED_ANIMATION((matched) => (state) => { 194 | state.currentLine.dispatch.SHOW_MATCHED_ANIMATION(matched) 195 | }) 196 | 197 | // 198 | on.NOT_IN_WORD_LIST(() => (state, dispatch) => { 199 | dispatch.SHOW_TOAST("Not In word list") 200 | }) 201 | 202 | on.GAME_END(() => (state, dispatch) => { 203 | dispatch.SHOW_TOAST("정답은 " + state.answer + " 입니다.", TOAST_DURATION_LONG) 204 | }) 205 | }) 206 | }) 207 | 208 | const useEvent = ( 209 | target: EventTarget, 210 | type: string, 211 | callback: EventListenerOrEventListenerObject | null, 212 | options?: AddEventListenerOptions | boolean 213 | ) => { 214 | useEffect(() => { 215 | target.addEventListener(type, callback, options) 216 | return () => target.removeEventListener(type, callback) 217 | }) 218 | } 219 | 220 | function WordleLine() { 221 | const {chars} = useLine() 222 | 223 | return ( 224 | <> 225 |
    226 | {chars.map((char, index) => ( 227 |
    228 | {char} 229 |
    230 | ))} 231 |
    232 | 233 | ) 234 | } 235 | 236 | export default function Wordle() { 237 | const lines = Array(5).fill("") 238 | 239 | const {dispatch} = useStore() 240 | 241 | useEvent(window, "keydown", (e) => { 242 | if (/^[a-zA-Z]$/.test(e.key)) { 243 | dispatch.PUSH_KEY(e.key.toUpperCase()) 244 | } else if (e.key === "Backspace") { 245 | dispatch.BACKSPACE() 246 | } else if (e.key === "Enter") { 247 | dispatch.ENTER() 248 | } 249 | }) 250 | 251 | return ( 252 | <> 253 |
    254 | {lines.map((line, index) => ( 255 | 256 | 257 | 258 | ))} 259 |
    260 | 261 | ) 262 | } 263 | -------------------------------------------------------------------------------- /src/test.tsx: -------------------------------------------------------------------------------- 1 | function TodoItem() { 2 | const {text, completed, dispatch} = useTodoItemStore() 3 | 4 | const toggleTodo = () => dispatch.TOGGLE() 5 | 6 | return ( 7 |
  • 8 | {text} 9 |
  • 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/test/new.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from "vitest" 2 | import {createDraftProxy, createStorePart} from "./newStore.ts" 3 | 4 | describe("Draft Test", () => { 5 | it("Draft Test", () => { 6 | const {store, reducer} = createStorePart() 7 | const state = store 8 | 9 | store.sum = reducer((state) => state.x + state.y) 10 | 11 | state.x = 100 12 | state.y = 200 13 | state.foo = {bar: 300, baz: 400} 14 | 15 | console.log("@@@", store) 16 | console.log("sum", store.sum) 17 | 18 | expect(state.x).toBe(100) 19 | expect(state.y).toBe(200) 20 | expect(state.sum).toBe(300) 21 | expect(state.foo.bar).toBe(300) 22 | 23 | // Draft는 생성 당시 state의 값을 그대로 출력한다. 24 | const draft = createDraftProxy(store) 25 | const draft2 = createDraftProxy(store) 26 | 27 | expect(draft.x).toBe(100) 28 | expect(draft.y).toBe(200) 29 | expect(draft.foo.bar).toBe(300) 30 | 31 | expect(draft2.x).toBe(100) 32 | expect(draft2.y).toBe(200) 33 | expect(draft2.foo.bar).toBe(300) 34 | 35 | // draft의 값이 바뀐다고 다른 state나 draft의 값이 바뀌지는 않는다. 36 | draft.x = 10 37 | expect(draft.x).toBe(10) 38 | expect(draft2.x).toBe(100) 39 | expect(state.x).toBe(100) 40 | 41 | // 원본 state의 값이 바뀌어도 draft의 값은 유지된다. 42 | state.x = 50 43 | expect(draft.x).toBe(10) 44 | 45 | // 단, draft의 값을 수정하지 않았다면 기존 draft는 state의 값을 따라간다. 46 | expect(draft2.x).toBe(50) 47 | 48 | console.log({draft}) 49 | console.log({draft2}) 50 | }) 51 | }) 52 | 53 | describe("[new] Store와 리듀서", () => { 54 | it("Store와 Computed Value Reducer", () => { 55 | const {store, reducer} = createStorePart() 56 | 57 | // store에 작성한 값은 바로 사용할 수 있다. 58 | store.x = 100 59 | expect(store.x).toBe(100) 60 | 61 | // store에 작성된 reducer의 값은 store에 반영된다. 62 | store.y = reducer(50) 63 | expect(store.y).toBe(50) 64 | 65 | // store에 작성된 reducer의 값은 computedValue는 store에 반영된다. 66 | store.doubledX = reducer((state) => state.x * 2) 67 | expect(store.doubledX).toBe(200) 68 | 69 | // computed Value는 연속해서 접근할 수 있다. 70 | store.sum = reducer((state) => state.doubledX + state.y) 71 | expect(store.sum).toBe(250) 72 | 73 | // 값이 변경되면 computed Value에도 반영이 된다. 74 | store.x = 25 75 | expect(store.x).toBe(25) 76 | expect(store.doubledX).toBe(50) 77 | expect(store.sum).toBe(100) 78 | 79 | // 값이 변경되면 computed Value에도 반영이 된다. 80 | store.y = 100 81 | expect(store.y).toBe(100) 82 | expect(store.doubledX).toBe(50) 83 | expect(store.sum).toBe(150) 84 | }) 85 | 86 | it("Store와 Array", () => { 87 | const {store, reducer} = createStorePart() 88 | 89 | // store에 array가 가능하다. 90 | store.arr = [1, 2, 3] 91 | expect(store.arr).toEqual([1, 2, 3]) 92 | 93 | // Array의 map과 같은 네이티브값을 써도 문제가 없다. 94 | store.arr2 = store.arr.map((x) => x * 2) 95 | expect(store.arr2).toEqual([2, 4, 6]) 96 | 97 | // Array의 map과 같은 네이티브값을 써도 문제가 없다. 98 | store.arr3 = reducer(() => [1, 2]) 99 | expect(store.arr3).toEqual([1, 2]) 100 | 101 | store.arr4 = reducer((state) => state.arr3.map((x) => x * 3)) 102 | expect(store.arr4).toEqual([3, 6]) 103 | }) 104 | 105 | it("Reducer와 dispatch", () => { 106 | const {store, reducer, dispatch, snapshot} = createStorePart() 107 | 108 | store.count = reducer(0, (on) => { 109 | on.INCREASE((by) => (state) => (state.count += by)) 110 | on.DECREASE((by) => (state) => (state.count -= by)) 111 | on.RESET(() => (state) => (state.count = 0)) 112 | }) 113 | 114 | store.doubledCount = reducer((state) => state.count * 2) 115 | 116 | const [state] = snapshot() 117 | 118 | expect(state.count).toBe(0) 119 | expect(state.doubledCount).toBe(0) 120 | 121 | dispatch.INCREASE(1) 122 | expect(state.count).toBe(1) 123 | expect(state.doubledCount).toBe(state.count * 2) 124 | 125 | dispatch.INCREASE(5) 126 | expect(state.count).toBe(6) 127 | expect(state.doubledCount).toBe(state.count * 2) 128 | 129 | dispatch.DECREASE(2) 130 | expect(state.count).toBe(4) 131 | expect(state.doubledCount).toBe(state.count * 2) 132 | 133 | dispatch.RESET() 134 | expect(state.count).toBe(0) 135 | expect(state.doubledCount).toBe(state.count * 2) 136 | }) 137 | 138 | it("Snapshot", () => { 139 | const {store, reducer, dispatch, snapshot} = createStorePart() 140 | 141 | store.count = reducer(0, (on) => { 142 | on.INCREASE((by) => (state) => (state.count += by)) 143 | on.DECREASE((by) => (state) => (state.count -= by)) 144 | on.RESET(() => (state) => (state.count = 0)) 145 | }) 146 | 147 | store.test = reducer(0, (on) => { 148 | on.INCREASE(() => (state) => state.test++) 149 | }) 150 | 151 | store.doubledCount = reducer((state) => state.count * 2) 152 | 153 | store.foo = {} 154 | store.foo.bar = 200 155 | 156 | const [state, subscribe, marked] = snapshot() 157 | 158 | console.log("markedmarked", marked) 159 | 160 | // console.log("#", state.test) 161 | // console.log("#", state.count) 162 | // console.log("#", state.foo.bar) 163 | console.log("#", state.doubledCount) 164 | 165 | console.log("markedmarked", marked) 166 | 167 | dispatch.INCREASE(1) 168 | // dispatch.DECREASE(1) 169 | // dispatch.RESET() 170 | 171 | console.log("markedmarked", marked) 172 | 173 | console.log(state.doubledCount) 174 | 175 | dispatch.INCREASE(1) 176 | 177 | console.log(state.doubledCount) 178 | 179 | dispatch.INCREASE(1) 180 | 181 | console.log(state.doubledCount) 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /src/test/newStore.ts: -------------------------------------------------------------------------------- 1 | import {createContext, createElement, Key, ReactNode, useContext, useEffect, useState} from "react" 2 | 3 | const NON_PROXY_TYPES = [Function, Date, RegExp, Set, Map, WeakSet, WeakMap, Error, ArrayBuffer, globalThis.Node || Function] 4 | 5 | const isObject = (target: unknown): target is object => Object(target) === target 6 | 7 | const isProxiable = (target: unknown) => isObject(target) && !NON_PROXY_TYPES.some((type) => target instanceof type) 8 | 9 | const valueOf = (target: T): T => (target && typeof target.valueOf === "function" ? (target.valueOf() as T) : target) 10 | 11 | const mapCacheValueOf = (key: unknown, map: Map, data: T): T => 12 | map.has(key) ? (map.get(key) as T) : void map.set(key, data) || data 13 | 14 | const trackedObjects = new Set() 15 | 16 | // 17 | // 18 | // Store 19 | // -------------------------------------------------------------------- 20 | type Mutable = { 21 | -readonly [P in keyof T]: T[P] 22 | } 23 | 24 | const STORE = Symbol("@@store") 25 | const registerStorePrototype = (target: object) => { 26 | let proto = Object.getPrototypeOf(target) 27 | if (proto && STORE in proto) { 28 | return proto 29 | } 30 | 31 | proto = {[STORE]: true} 32 | Object.setPrototypeOf(target, proto) 33 | return proto 34 | } 35 | 36 | // createStoreProxy(): 상태 관리를 위한 Proxy를 생성합니다. 상태 객체의 속성 접근과 변경을 감시하며, Reducer를 통해 계산된 값을 관리합니다. 37 | 38 | // @FIXME: computed value를 Object geterr와 Proxy get trap에서 둘다 쓰고 있다. 하나는 필요없다. 39 | // @FIXME: 항상 계산하는 게 아니라 관련 데이터가 업데이트 되었을때에만 갱신하는 방식을 구현하자! 40 | 41 | // @FIXME: get에서 Proxy를 만들지 말고 set에서 만들자! 42 | const storeProxyMap = new Map() 43 | 44 | function createStoreProxy(obj: State | object, root: State): State { 45 | if (!isProxiable(obj)) return obj as State 46 | return mapCacheValueOf( 47 | obj, 48 | storeProxyMap, 49 | new Proxy(obj, { 50 | get(target, prop, receiver) { 51 | if (prop === "valueOf") { 52 | return () => target 53 | } 54 | 55 | const result = Reflect.get(target, prop, receiver) 56 | if (!trackedObjects.has(result)) { 57 | return result 58 | } 59 | 60 | return createStoreProxy(valueOf(result), root) 61 | }, 62 | 63 | // store.id = key 64 | // store.val = value 65 | // store.count = reducer(0, {...}) 66 | set(target, prop, value, receiver) { 67 | const currentValue = Reflect.get(target, prop, receiver) 68 | if (Object.is(currentValue, value)) { 69 | return true 70 | } 71 | 72 | // set reducer 73 | if (value instanceof Reducer) { 74 | // register Reducer 75 | const reducer = value 76 | const proto = registerStorePrototype(target) 77 | proto[prop] = reducer 78 | 79 | // Computed Getter 80 | if (reducer.computed) { 81 | const [state, _, marker, reconcile] = createSnapshot(root) 82 | let isDirty = true 83 | let computedValue: unknown 84 | 85 | Object.defineProperty(proto, prop, { 86 | get: () => { 87 | // reconcile(() => (isDirty = true)) 88 | computedValue = isDirty ? reducer.computed(state) : computedValue 89 | if (isDirty) { 90 | isDirty = marker.size === 0 91 | } 92 | isDirty = true 93 | return computedValue 94 | }, 95 | configurable: true, 96 | }) 97 | 98 | return true 99 | } 100 | 101 | // init Reducer value 102 | if (currentValue === undefined) { 103 | return Reflect.set(target, prop, reducer.value, receiver) 104 | } 105 | 106 | return true 107 | } 108 | 109 | // update mark: version up! 110 | return Reflect.set(target, prop, value, receiver) 111 | }, 112 | }) 113 | ) as State 114 | } 115 | 116 | // 117 | // 118 | // Snapshot 119 | // ---------------------------------------------------------------- 120 | 121 | const compareSnapshotKey = (obj1: Record, obj2: Record) => { 122 | const keys1 = Object.keys(obj1) 123 | for (const key of keys1) if (obj1[key] !== obj2[key]) return false 124 | return true 125 | } 126 | 127 | const createSnapshotPath = (obj: object, marked: Map>, snapshotMap): object => { 128 | if (!isProxiable(obj)) return obj 129 | return mapCacheValueOf( 130 | obj, 131 | snapshotMap, 132 | new Proxy(obj, { 133 | get(target, prop, receiver) { 134 | if (prop === "valueOf") { 135 | return () => target 136 | } 137 | 138 | const mark = mapCacheValueOf(target, marked, {}) 139 | mark[prop] = target[prop] 140 | trackedObjects.add(target) 141 | 142 | const result = Reflect.get(target, prop, receiver) 143 | return createSnapshotPath(result, marked, snapshotMap) 144 | }, 145 | }) as object 146 | ) 147 | } 148 | 149 | function createSnapshot(store: State) { 150 | const marker = new Map>() 151 | const snapshotMap = new Map() 152 | const snapshot = createSnapshotPath(store, marker, snapshotMap) as State 153 | 154 | const updateCallbacks = new Set() 155 | 156 | const reconcile = (ifDirty: Function) => { 157 | for (const [target, lastVersions] of marker) { 158 | if (!compareSnapshotKey(lastVersions, target)) { 159 | // console.log("dirty!", lastVersions, target) 160 | ifDirty(lastVersions) 161 | return true 162 | } 163 | } 164 | 165 | return false 166 | } 167 | 168 | const mutationHandler = (...args) => { 169 | reconcile((snapshotKeys) => { 170 | updateCallbacks.forEach((cb) => cb(snapshotKeys)) 171 | marker.clear() 172 | }) 173 | } 174 | 175 | const subscribe = (callback: Function) => { 176 | updateCallbacks.add(callback) 177 | const unsubscribe = subscribeMutation(mutationHandler) 178 | return () => { 179 | updateCallbacks.delete(callback) 180 | if (updateCallbacks.size === 0) unsubscribe() 181 | } 182 | } 183 | 184 | return [snapshot, subscribe, marker, reconcile] as const 185 | } 186 | 187 | // 188 | // 189 | // Draft 190 | // ----------------------------------------------------------------------- 191 | const createDraftProxyPath = (obj: object, draftMap: Map): object => { 192 | if (!isProxiable(obj)) return obj 193 | return mapCacheValueOf( 194 | obj, 195 | draftMap, 196 | new Proxy(obj, { 197 | get(target, prop, receiver) { 198 | if (prop === "valueOf") { 199 | return () => target 200 | } 201 | 202 | const result = Reflect.get(target, prop, receiver) 203 | if (Reflect.getOwnPropertyDescriptor(target, prop)) { 204 | return result 205 | } 206 | 207 | if (isProxiable(result)) { 208 | Reflect.set(target, prop, Object.create(valueOf(result)), receiver) 209 | return createDraftProxyPath(result, draftMap) 210 | } 211 | 212 | return result 213 | }, 214 | 215 | deleteProperty(target, prop) { 216 | return Reflect.set(target, prop, undefined) 217 | }, 218 | }) as object 219 | ) 220 | } 221 | 222 | export function createDraftProxy(store: State): State { 223 | const draftMap = new Map() 224 | return createDraftProxyPath(Object.create(store), draftMap) as State 225 | } 226 | 227 | // 228 | // 229 | // Reducer 230 | // ----------------------------------------------------------------------- 231 | type Selector = (state: State) => T 232 | type Init = T | Selector 233 | 234 | type MutateFn = (state: State, dispatch: Actions) => void | unknown | Promise 235 | type On = { 236 | [K in keyof Actions]: (fn: (...args: Actions[K] extends (...args: infer P) => unknown ? P : never[]) => MutateFn) => void 237 | } 238 | type ConditionFn = (state: State) => boolean 239 | type Can = { 240 | [K in keyof Actions]: (fn: (...args: Actions[K] extends (...args: infer P) => unknown ? P : never[]) => ConditionFn) => void 241 | } 242 | type ReducerFn = (on: On) => void 243 | 244 | const noop = () => {} 245 | 246 | class Reducer { 247 | public value: T | undefined 248 | public computed: (state: State) => T 249 | public guardFn: ReducerFn = () => true 250 | 251 | constructor( 252 | public init: Init, 253 | public reducerFn: ReducerFn, 254 | public state: State 255 | ) { 256 | this.value = typeof init !== "function" ? init : undefined 257 | this.computed = typeof init === "function" ? init : undefined 258 | } 259 | } 260 | 261 | // 262 | // 263 | // Dispatcher 264 | // ---------------------------------------------------------------- 265 | type Dispatch = Actions & ((type: string, ...payload: unknown[]) => void) 266 | 267 | const traverseReducer = ( 268 | obj: T, 269 | callback: (value: unknown, path: string[], prop: string) => void | false, 270 | path: string[] = [] 271 | ) => { 272 | if (!isObject(obj)) { 273 | return 274 | } 275 | 276 | // @FIXME: 1depth만 하자!! 깊이 다 들어가면 너무 많은 reducer를 찾아야 한다!! 277 | // @FIXME: 아니라면 store에 사용하는 reducer만 보관하는 로직을 작성해야한다. 278 | Object.entries(Object.getPrototypeOf(obj)).forEach(([key, value]) => { 279 | const currentPath = [...path, key] 280 | const res = callback(value, path, key) 281 | // if (res !== false && isObject(value)) { 282 | // traverseReducer(value, callback, currentPath) 283 | // } 284 | }) 285 | } 286 | 287 | const deepMerge = (target: Record, source: Record, flagDeleteUndefined = false) => { 288 | Object.keys(source).forEach((key) => { 289 | if (isObject(source[key])) { 290 | target[key] = target[key] || {} 291 | deepMerge(target[key], source[key]) 292 | } else { 293 | if (flagDeleteUndefined && source[key] === undefined) { 294 | delete target[key] 295 | } else { 296 | target[key] = source[key] 297 | } 298 | } 299 | }) 300 | return target 301 | } 302 | 303 | const createCan = (type: string, payload: unknown[], draft: State) => { 304 | const guard = {isPass: true} 305 | return [ 306 | guard, 307 | new Proxy(Function, { 308 | get: (_, actionType: string) => (fn: (...args: unknown[]) => ConditionFn) => { 309 | if (actionType === type) { 310 | if (guard.isPass && !fn(...payload)(draft)) { 311 | guard.isPass = false 312 | } 313 | } 314 | }, 315 | }) as Can, 316 | ] as const 317 | } 318 | 319 | const createOn = (type: string, payload: unknown[], draft: State, dispatch: Dispatch) => { 320 | return new Proxy(Function, { 321 | get: (_, actionType: string) => (fn: (...args: unknown[]) => MutateFn) => { 322 | if (actionType === type) { 323 | // @TODO: fn이 function이 아니라 값이라면 바로 값을 넣어 줄 수 있다. 324 | if (typeof fn !== "function") { 325 | // @TODO: drfat[prop] = fn 326 | // @TODO: 그럴려면 prop이름을 store.reducer에서 알 수 있어야 한다. 327 | } 328 | 329 | // @TODO: state에 뭔가를 추적을 할 수 있는 것들을 넣으면 좋겠는데... 330 | const result = fn(...payload)(draft, dispatch) 331 | 332 | // @TODO: reuslt 비동기처리 333 | } 334 | }, 335 | 336 | apply(_, __, argumentsList) { 337 | //TODO 338 | console.log(argumentsList) 339 | }, 340 | }) as On 341 | } 342 | 343 | const mutationCallbackSet = new Set() 344 | 345 | const subscribeMutation = (callback: Function) => { 346 | mutationCallbackSet.add(callback) 347 | return () => mutationCallbackSet.delete(callback) 348 | } 349 | 350 | const publishMutation = (...args) => { 351 | mutationCallbackSet.forEach((cb) => cb(...args)) 352 | } 353 | 354 | function createDispatch(store: State, state: State, options) { 355 | const defaultOptions = {} 356 | options = {...defaultOptions, ...options} 357 | const middleware = options.middleware 358 | 359 | const dispatchAction = (type: string, payload: unknown[]) => { 360 | const stateChanges = {} 361 | 362 | // reduce 실행 363 | traverseReducer(store, (reducer, path, prop) => { 364 | if (reducer instanceof Reducer && reducer.state === state) { 365 | const draft = createDraftProxy(state) 366 | const on = createOn(type, payload, draft, dispatch) 367 | reducer.reducerFn(on) 368 | deepMerge(stateChanges, draft) 369 | return false 370 | } 371 | }) 372 | 373 | deepMerge(store, stateChanges, true) 374 | publishMutation(state, stateChanges) 375 | trackedObjects.clear() 376 | } 377 | 378 | const next = ({type, payload}) => dispatchAction(type, payload) 379 | 380 | const dispatch = new Proxy(Function, { 381 | get(_, type: string) { 382 | return (...payload: unknown[]) => { 383 | const action = {type, payload} 384 | middleware({dispatch, state, options, getState: () => state})(next)(action) 385 | } 386 | }, 387 | 388 | apply(_, thisArg, argumentsList) { 389 | //TODO 390 | const [type, ...payload] = argumentsList 391 | return dispatch[type](...payload) 392 | }, 393 | }) as Dispatch 394 | 395 | return dispatch 396 | } 397 | 398 | // 399 | // 400 | // UseStore for React 401 | // ---------------------------------------------------------------------------------------------- 402 | type UseStore = Readonly & {dispatch: Dispatch} 403 | 404 | const useStoreFactory = (_state: State) => { 405 | const [, setVersion] = useState(0) 406 | const [state, subscribe] = createSnapshot(_state) 407 | useEffect(() => subscribe(() => setVersion((version) => version + 1)), [subscribe]) 408 | return state as UseStore 409 | } 410 | 411 | // 412 | // 413 | // createStorePart 414 | // ----------------------------------------------------------------------------------------------- 415 | 416 | let logger_index = 1 417 | const logger = (api) => (next) => (action) => { 418 | if (!globalThis.document) { 419 | next(action) 420 | return 421 | } 422 | 423 | const debugLabel = api.options?.debugLabel ?? "" 424 | 425 | const {type, payload} = action 426 | console.group("#" + logger_index++, debugLabel, type + "(", ...payload, ")") 427 | console.groupCollapsed("(callstack)") 428 | console.trace("") 429 | console.groupEnd() 430 | 431 | try { 432 | next(action) 433 | } catch (e) { 434 | const state = api.getState() 435 | console.log(state) 436 | throw e 437 | } 438 | console.log(api.getState()) 439 | console.groupEnd() 440 | } 441 | 442 | const tmpOption = { 443 | middleware: logger, 444 | } 445 | 446 | export function createStorePart(options = {}) { 447 | options = {...tmpOption, ...options} 448 | 449 | const state: State = (options.initValue ?? {}) as State 450 | 451 | const store = createStoreProxy(state, state) 452 | 453 | const snapshot = () => createSnapshot(state) 454 | 455 | const dispatch = createDispatch(store, state, options) 456 | const proto = registerStorePrototype(store) 457 | proto.dispatch = dispatch 458 | 459 | const reducer = (init: Init, fn: ReducerFn = noop): R => new Reducer(init, fn, state) as R 460 | 461 | reducer.withGuard = (fn) => { 462 | return (init, fn) => { 463 | const r = reducer(init, fn) 464 | r.guardFn = fn 465 | return r 466 | } 467 | } 468 | 469 | // 470 | // React 471 | const useStore = (debugLabel: string = "") => useStoreFactory(state) 472 | 473 | return {store, snapshot, reducer, dispatch, useStore} 474 | } 475 | 476 | // 477 | // 478 | // createStore 479 | // ---------------------------------------------------------------------------------- 480 | interface ReducerFactoryFn { 481 | (init: Init, fn?: ReducerFn): R 482 | withGuard(fn: (can: Can) => void): ReducerFactoryFn 483 | } 484 | 485 | interface Builder { 486 | store: Mutable 487 | reducer: ReducerFactoryFn 488 | dispatch: Dispatch 489 | } 490 | 491 | export function createStore( 492 | init: (builder: Builder) => void = noop, 493 | options = tmpOption 494 | ) { 495 | const {store, reducer, dispatch, useStore} = createStorePart(options) 496 | init({store, reducer, dispatch}) 497 | return useStore 498 | } 499 | 500 | // 501 | // 502 | // createComponentStore 503 | // ------------------------------------------------------------------------------------------ 504 | export function createComponentStore(init: (builder: Builder) => void, label: string = "") { 505 | const ComponentStoreContext = createContext("") 506 | 507 | const memo = Object.create(null) as Record UseStore> 508 | 509 | const repository: Record}> = {} 510 | 511 | // @FIXME: 리파지토리 개념 다시 만들자! 512 | repository.of = (id: string) => { 513 | if (memo[id]) return repository[id] 514 | 515 | repository[id] = repository[id] || {} 516 | const options = { 517 | initValue: repository[id], 518 | debugLabel: label + "." + id, 519 | } 520 | 521 | if (!memo[id]) memo[id] = createStore(init, options) 522 | return repository[id] 523 | } 524 | 525 | const useComponentStore = (id: PropertyKey | undefined = undefined, ...args) => { 526 | const contextId = useContext(ComponentStoreContext) 527 | id = id ?? contextId 528 | 529 | // @FIXME!! 530 | // console.log("id!!!", id) 531 | repository.create(id) 532 | const useStore = memo[id] 533 | return useStore(id, ...args) 534 | } 535 | 536 | const ComponentStoreProvider = (props: {id: string | number; key: Key; children: ReactNode}) => 537 | createElement(ComponentStoreContext.Provider, {value: props.id}, props.children) 538 | 539 | return [useComponentStore, ComponentStoreProvider, repository] as const 540 | } 541 | -------------------------------------------------------------------------------- /src/test/proxy.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from "vitest" 2 | import {createDraftProxy, createStorePart} from "./newStore.ts" 3 | 4 | // ----------------------------------------------------------------------------------------------------- 5 | interface State { 6 | x: number 7 | y: number 8 | z: number 9 | sum: number 10 | 11 | arr: number[] 12 | computedArray: number[] 13 | 14 | count: number 15 | doubledCount: number 16 | 17 | foo: { 18 | bar: number 19 | baz: number 20 | nestedComputedArray: number[] 21 | } 22 | } 23 | 24 | interface Actions { 25 | INCREASE(by: number): void 26 | DECREASE(by: number): void 27 | RESET(): void 28 | } 29 | 30 | describe("proxy", () => { 31 | it("Computed", () => { 32 | const {store, reducer, snapshot} = createStorePart() 33 | 34 | store.arr = [1, 2, 3] 35 | store.computedArray = reducer((state) => []) 36 | 37 | store.foo = {} 38 | store.foo.nestedComputedArray = reducer((state) => []) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/test/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it, vi} from "vitest" 2 | import {createStorePart} from "./newStore.ts" 3 | 4 | interface State { 5 | count: number 6 | doubledCount: number 7 | onlyUp: number 8 | doubledOnlyUp: number 9 | } 10 | 11 | interface Actions { 12 | INCREASE(by: number): void 13 | DECREASE(by: number): void 14 | RESET(): void 15 | } 16 | 17 | describe.only("reducer", () => { 18 | it("reducer Counter", () => { 19 | const {store, reducer, dispatch, snapshot} = createStorePart() 20 | 21 | store.count = reducer(0, (on) => { 22 | on.INCREASE((by) => (state) => (state.count += by)) 23 | on.DECREASE((by) => (state) => (state.count -= by)) 24 | on.RESET(() => (state) => (state.count = 0)) 25 | }) 26 | 27 | store.onlyUp = reducer(0, (on) => { 28 | on.INCREASE((by) => (state) => (state.onlyUp += by)) 29 | }) 30 | 31 | const computedDoubledCount = vi.fn((state) => state.count * 2) 32 | const computedDoubledOnlyUp = vi.fn((state) => state.onlyUp * 2) 33 | 34 | store.doubledCount = reducer(computedDoubledCount) 35 | 36 | store.doubledOnlyUp = reducer(computedDoubledOnlyUp) 37 | 38 | // 39 | const [state] = snapshot() 40 | 41 | expect(state.count).toBe(0) 42 | // expect(state.doubledCount).toBe(0) 43 | // expect(state.doubledCount).toBe(0) 44 | // expect(state.doubledCount).toBe(0) 45 | // console.log(state) 46 | 47 | // expect(computedDoubledCount).toBeCalledTimes(1) 48 | // expect(computedDoubledOnlyUp).toBeCalledTimes(0) 49 | 50 | dispatch.INCREASE(1) 51 | // expect(state.count).toBe(1) 52 | // expect(state.onlyUp).toBe(1) 53 | // expect(state.doubledCount).toBe(2) 54 | 55 | // // expect(computedDoubledCount).toBeCalledTimes(2) 56 | // // expect(computedDoubledOnlyUp).toBeCalledTimes(1) 57 | // 58 | // dispatch.INCREASE(5) 59 | // expect(state.count).toBe(6) 60 | // expect(state.onlyUp).toBe(6) 61 | // expect(state.doubledCount).toBe(12) 62 | // 63 | // dispatch.DECREASE(2) 64 | // expect(state.count).toBe(4) 65 | // expect(state.onlyUp).toBe(6) 66 | // expect(state.doubledCount).toBe(8) 67 | // 68 | // dispatch.RESET() 69 | // expect(state.count).toBe(0) 70 | // expect(state.onlyUp).toBe(6) 71 | // expect(state.doubledCount).toBe(0) 72 | 73 | // expect(computedDoubledCount).toBeCalledTimes(1) 74 | // expect(computedDoubledCount).toBeCalledTimes(1) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vite" 2 | import react from "@vitejs/plugin-react" 3 | import {adorableCSS} from "adorable-css/vite" 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [adorableCSS(), react()], 8 | }) 9 | --------------------------------------------------------------------------------