Hubbleのフロントエンド状態管理の歴史とSignalの可能性

これは [Hubble Advent Calendar 2024](https://qiita.com/advent-calendar/2024/hubble) の14日目[^1]の記事です。

## はじめに

株式会社Hubbleでフロントエンドを担当している @KOHETs です。

Hubbleのフロントエンド開発では、状態管理に Container/Presentationalパターン を取り入れ、UIとロジックの分離を一貫して続けてきました。また、状態管理は少しずつ形を変えながら、Global Store、Feature Store、Component Store の3つの性質をうまく使い分けてきました。

この記事では、その歴史を振り返りながら、初期のシンプルな状態管理からどのように設計を進化させてきたのか、各フェーズの課題や解決策を紹介します。

## 初期フェーズ: Service + BehaviorSubject

最初は複雑な設計が必要なライブラリは避けて、Angular の標準機能だけでシンプルに実装していました。状態管理には Service + BehaviorSubject を採用し、UIとロジックを分ける形を意識して進めていました。

```typescript:todo-state.service.ts
@Injectable({ providedIn: 'root' })
export class TodoStateService {
  private _todos$ = new BehaviorSubject<Todo[]>([]);

  constructor(private todoService: TodoService) {}

  get todos$() {
    return this._todos$.asObservable();
  }

  loadTodos() {
    this.todoService.loadTodos().subscribe({
      next: (todos) => {
        this._todos$.next(todos);
      },
      error: (err) => {
        this._todos$.next([]);
      },
    });
  }

  addTodo(title: string) {
    this.todoService.addTodo(title).subscribe({
      next: (todo) => {
        this._todos$.next([...this._todos$.value, todo]);
      },
      error: (err) => {
        console.error('Failed to add todo', err);
      },
    });
  }
}
```

```typescript:todo-container.component.ts
@Component({
  selector: 'app-todo-container',
  template: `
    <ng-container *ngIf="todos$ | async as todos">
      <ul>
        <li *ngFor="let todo of todos">
          {{ todo.title }}
        </li>
      </ul>
    </ng-container>
  `,
})
export class TodoContainerComponent {
  todos$ = this.todoStateService.todos$;

  constructor(private todoStateService: TodoStateService) {
    this.todoStateService.loadTodos();
  }

  // 追加や他の処理もここに追加可能
}
```

この形での状態管理はシンプルで十分機能していたものの、状態のリセットや初期化など、自前で細かく管理するのが思ったよりも面倒になってきました。そこで、実装開始から3ヶ月後には NgRx の導入を決めました。

## @ngrx/store の導入

状態管理が煩雑になり始めたタイミングで、@ngrx/store と @ngrx/effects を導入し、状態管理の構造を見直しました。状態は Global Store と Feature Store に集約し、APIの呼び出しはすべてeffectに任せることで、Containerコンポーネントは状態の取得だけに集中できるようになりました。

```typescript:todo.actions.ts
export const loadTodos = createAction('[Todo] Load Todos');
export const loadTodosSuccess = createAction('[Todo] Load Todos Success', props<{ todos: Todo[] }>());
export const loadTodosFailure = createAction('[Todo] Load Todos Failure', props<{ error: string }>());

export const addTodo = createAction('[Todo] Add Todo', props<{ title: string }>());
export const addTodoSuccess = createAction('[Todo] Add Todo Success', props<{ todo: Todo }>());
export const addTodoFailure = createAction('[Todo] Add Todo Failure', props<{ error: string }>());
```

```typescript:todo.reducer.ts
export const todoAdapter = createEntityAdapter<Todo>();

export interface TodoState extends EntityState<Todo> {}

export const initialState: TodoState = todoAdapter.getInitialState({});

export const todoReducer = createReducer(
  initialState,
  on(loadTodosSuccess, (state, { todos }) =>
    todoAdapter.setAll(todos, { ...state, loading: false, error: null })
  ),
  on(addTodoSuccess, (state, { todo }) =>
    todoAdapter.addOne(todo, state)
  )
);
```

```typescript:todo.effects.ts
@Injectable()
export class TodoEffects {
  constructor(private actions$: Actions, private todoService: TodoService) {}

  loadTodos$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.loadTodos),
      mergeMap(() =>
        this.todoService.loadTodos().pipe(
          map((todos) => TodoActions.loadTodosSuccess({ todos })),
          catchError((error) => of(TodoActions.loadTodosFailure({ error: error.message })))
        )
      )
    )
  );

  addTodo$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.addTodo),
      mergeMap(({ todo }) =>
        this.todoService.addTodo(todo.title).pipe(
          map((newTodo) => TodoActions.addTodoSuccess({ todo: newTodo })),
          catchError((error) => of(TodoActions.addTodoFailure({ error: error.message })))
        )
      )
    )
  );
}
```

```typescript:todo.selectors.ts
export const selectTodoState = createFeatureSelector<TodoState>('todos');

const { selectAll, selectEntities } = todoAdapter.getSelectors();

export const selectAllTodos = createSelector(selectTodoState, selectAll);
```

```typescript:todo-container.component.ts
@Component({
  selector: 'app-todo-container',
  template: `
    <ng-container *ngIf="todos$ | async as todos">
      <ul>
        <li *ngFor="let todo of todos">
          {{ todo.title }}
        </li>
      </ul>
    </ng-container>
  `,
})
export class TodoContainerComponent {
  todos$ = this.store.select(TodoSelectors.selectAllTodos);

  constructor(private store: Store) {
    this.store.dispatch(TodoActions.loadTodos());
  }

  // 追加や他の処理もここに追加可能
}
```


この設計のおかげでAPI呼び出しが一元化され、実装時の迷いがなくなったのは良かった点です。ただし、action、reducer、selector、effect を分割して管理する必要があったので、ファイル数が増え、状態管理周りの作業は次第に面倒になってきました。

## @ngrx/component-store の導入

NgRxの導入で状態管理は整理されたものの、Component Store の層もFeature Storeに集約されていたため、状態管理の実装がどうしても複雑に感じるようになりました。そんな中、新しく登場した @ngrx/component-store がこの課題を解決してくれると考え、すぐに導入しました。

これまでFeature Storeで管理していた Component単位の状態 を Component Store に移行したことで、状態管理はかなりシンプルになりました。ただ、AsyncPipe を使うためにContainerコンポーネントはWrapperとして残すことになり、完全に排除することはできませんでした。

```typescript:todo.store.ts
@Injectable()
export class TodoStore extends ComponentStore<{ todos: Todo[] }> {
  constructor(private todoService: TodoService) {
    super({ todos: [] });
  }

  readonly todos$ = this.select((state) => state.todos);

  readonly loadTodos = this.effect<void>((trigger$) =>
    trigger$.pipe(
      switchMap(() =>
        this.todoService.loadTodos().pipe(
          tapResponse({
            next: (todos) => this.patchState({ todos }),
            error: () => console.error('Failed to load todos'),
          })
        )
      )
    )
  );

  readonly createTodo = this.effect<string>((title$) =>
    title$.pipe(
      switchMap((title) =>
        this.todoService.addTodo(title).pipe(
          tapResponse({
            next: (todo) => this.patchState((state) => ({ todos: [...state.todos, todo] })),
            error: () => console.error('Failed to add todo'),
          })
        )
      )
    )
  );
}
```

```typescript:todo-container.component.ts
@Component({
  selector: 'app-todo-container',
  template: `
    <ng-container *ngIf="todos$ | async as todos">
      <ul>
        <li *ngFor="let todo of todos">
          {{ todo.title }}
        </li>
      </ul>
    </ng-container>
  `,
  providers: [TodoStore]
})
export class TodoContainerComponent {
  todos$ = this.todoStore.todos$;

  constructor(private todoStore: TodoStore) {
    this.todoStore.loadTodos();
  }

  // 追加や他の処理もここに追加可能
}
```

導入当時は情報が少なく、テストの書き方も手探り状態での実装でしたが、結果的には状態管理が整理され、今の開発環境の良さに繋がっています。

## Signalの登場

Component Storeの導入により、状態管理はかなり整理されましたが、依然として **AsyncPipe** のために必要だった **Containerコンポーネント** が課題として残っていました。しかし、Signal の登場により、この課題を解消することができました。

Signalを活用することで、状態を直接リアクティブにバインドできるようになり、Containerコンポーネントを排除して、よりシンプルな設計が実現しました。また、NgRx側が Signal に対応したことで、NgRxの良さを活かしつつ、新しいアプローチを取り入れることが可能になりました。

さらに、@ngrx/store には **Feature Creator** という便利な仕組みも追加されており、これらを組み合わせることで状態管理がより効率的かつシンプルになっています。

このあたりの詳細は、[@nishitaku さんが詳しく解説している記事](https://qiita.com/nishitaku/items/f1ccfccd5b2d0d197b16) にもまとめられているので、ぜひ参考にしてみてください。

## まとめ

Hubbleのフロントエンド開発では、状態管理を見直しながら進化させることで、現在の効率的なアーキテクチャを実現してきました。初期の **Service + BehaviorSubject** によるシンプルな設計から始まり、課題に応じて **NgRx** や **@ngrx/component-store** を導入。そして **Signal** の登場によって、さらなる効率化を果たしています。

日々の機能開発や改善に追われがちな中でも、アーキテクチャを見直すことは非常に重要です。一度決めた設計をそのままにするのではなく、現状に合った形に柔軟に更新していくことで、開発環境全体の質を向上させることができます。

## さいごに

ng-conf 2024 で **@ngrx/signals** は @ngrx/component-store の「成功版」と紹介されました。この新しいアプローチは **function-based** な思想に基づいており、開発体験を大きく変える一方で、その恩恵も非常に大きいはずですで、今後のNgRxの主流になっていくと考えられます。

ただし、Angularの Signal 自体はまだ進化の途中です。Hubbleではその動向を慎重に見極めながら、最適なタイミングで新しい技術を取り入れていく予定です。NgRx と Signal の組み合わせがどのように発展し、開発者にどんな可能性を広げてくれるのか、今後が非常に楽しみです。


明日は **@alstrocrack** さんです！

[^1]: 平日のみの投稿なので19日ですが、14日目の記事としています。