Hubbleフロントエンドの状態管理アーキテクチャ

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

# はじめに

株式会社Hubbleでフロントエンドを担当している [@nishitaku](https://qiita.com/nishitaku) です。

フロントエンド開発において、**状態管理**はアプリケーション全体の品質を左右する極めて重要な要素です。適切に設計された状態管理は、コードの保守性や拡張性を大幅に向上させます。

この記事では、Hubbleのフロントエンド開発で採用している状態管理アーキテクチャと、その設計思想について紹介します。


# NgRx

HubbleのフロントエンドはAngularをベースに実装しており、状態管理には[NgRx](https://ngrx.io/)を採用しています。NgRxは強力なライブラリですが、その性能を最大限に活かすためには、設計段階での工夫が欠かせません。

Hubbleでは、単一責任の原則に基づき、状態管理を以下の3種類のStoreに分類しています。
- Component Store
- Feature Store
- Global Store

## Component Store

特定のコンポーネントに紐づいた状態を管理するStoreです。このStoreはノードインジェクターに登録され、コンポーネントのライフサイクルと連動します。`@ngrx/component-store`を使用して実装されています。

`Component Store`と`Component`は、Container/Presetntationalパターンの関係性を持っています。ビジネスロジックや状態管理は`Component Store`に集約され、`Component`はUIの表示に特化し、原則状態を持ちません。

状態の変化は`Signal`を利用して追跡します。

<details>
<summary>サンプルコード</summary>

```typescript:movies.store.ts
interface MovieState {
  movies: Movie[];
}

@Injectable()
class MoviesStore extends ComponentStore<MovieState> {
  constructor() {
    super({movies: []});
  }

  readonly movies = this.selectSignal(
    (state) => state.movies,
  );
}
```

```typescript: movies-page.component.ts
@Component({
  template: `
    @for (movie of movies(); track movie.id) {
      {{ movie.title }}
    }
  `
  providers: [MoviesStore],
})
class MoviesPageComponent {
  private readonly componentStore = inject(MoviesStore);

  readonly movies = this.componentStore.movies;
}
```
</details>

## Feature Store

特定の機能単位で共有される状態を管理するStoreです。このStoreはRouteの環境インジェクターに登録され、Route配下のコンポーネントのみが利用できます。

状態管理の要素（actions、reducers、selectors）を一つのファイルに集約することで、コードの可読性と保守性を向上させています。

<details>
<summary>サンプルコード</summary>

```typescript:books.state.ts
export const BooksActions = createActionGroup({
  source: 'Books',
  events: {
    'Set Books': props<{ books: Book[] }>(),
    'Update Book': props<{ book: Book }>(),
    'Remove Book': props<{ book: Book }>(),
  }
});

interface State {
  books: Book[];
}

const initialState: State {
  books: [],
}

export const booksFeature = createFeature({
  name: 'books',
  reducer: createReducer(
    initialState,
    on(BooksActions.setBooks, (state, { books }) => ({
      ...state
      books
    })),
  ),
  extraSelectors: ({ selectBooks }) => {
    const selectFilteredBooks = createSelector(
      selectBooks,
      (books) => books.filter((book) => book.enabled)
    );

    return { selectFilteredBooks };
  },
});
```

```typescript:books-routes.ts
{
  path: 'books',
  providers: [
    provideState(booksState)
  ],
}
```
</details>

## Global Store

アプリケーション全体で共有されるグローバルな状態を管理するStoreです。ログイン情報やユーザー情報など、全体で使用するデータのみを扱い、ドメイン特化の状態は`Component Store`または`Feature Store`で管理します。

各種要素（`actions`、`effects`、`reducers`、`selectors`）はファイルを分割し、疎結合を保つ設計にしています。

<details>
<summary>サンプルコード</summary>

```typescript:auth.action.ts
export const AuthActions = createActionGroup({
  source: 'Auth',
  events: {
    'Login': props<{ user: User }>
    'Logout': emptyProps()
  }
});
```

```typescript: auth.reducer.ts
export interface AuthState {
  user: User | null;
}

const initialState: AuthState {
  user: null,
}

export const authReducer = createReducer(
  initialState,
  on(AuthActions.login, (state, { user }) => ({
    ...state
    user
  })),
  on(AuthActions.logout, (state) => ({
    ...state
    user: null
  })),
)
```

```typescript:auth.selectors.ts
const selectUser = createSelector(
  (state: AuthState) => state.user,
  user => user
);
```

```typescript:auth.effects:ts
@Injectable()
export class AuthEffects {
  logout$ = createEffect(() => this.actions$.pipe(
    ofType('AuthActions.logout'),
    exhaustMap(() => this.authService.logout()),
  );
}
```

```typescript: main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideStore({ [authFeatureKey]: authReducer }),
  ]
});
```
</details>


# 最後に

Hubbleに参画する前は、NgRxに対して「使いづらい」「難しい」という印象を持っていました。しかし、近年のアップデートによってAngularとの親和性が向上し、以前と比べて格段に使いやすくなっています。たとえば、`@ngrx/signals`など、Hubbleではまだ採用していない新機能も登場しており、今後の進化にも大いに期待しています。

この記事が、NgRxの魅力や可能性を知るきっかけになれば嬉しいです。ぜひ一度試してみてください！

明日は [@ic_lifewood](https://qiita.com/ic_lifewood)さんです！


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