これはHubble Advent Calendar 2024の9日目の記事です。1
はじめに
株式会社Hubbleでフロントエンドを担当している @nishitaku です。
フロントエンド開発において、状態管理はアプリケーション全体の品質を左右する極めて重要な要素です。適切に設計された状態管理は、コードの保守性や拡張性を大幅に向上させます。
この記事では、Hubbleのフロントエンド開発で採用している状態管理アーキテクチャと、その設計思想について紹介します。
NgRx
HubbleのフロントエンドはAngularをベースに実装しており、状態管理にはNgRxを採用しています。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
を利用して追跡します。
サンプルコード
interface MovieState {
movies: Movie[];
}
@Injectable()
class MoviesStore extends ComponentStore<MovieState> {
constructor() {
super({movies: []});
}
readonly movies = this.selectSignal(
(state) => state.movies,
);
}
@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;
}
Feature Store
特定の機能単位で共有される状態を管理するStoreです。このStoreはRouteの環境インジェクターに登録され、Route配下のコンポーネントのみが利用できます。
状態管理の要素(actions、reducers、selectors)を一つのファイルに集約することで、コードの可読性と保守性を向上させています。
サンプルコード
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 };
},
});
{
path: 'books',
providers: [
provideState(booksState)
],
}
Global Store
アプリケーション全体で共有されるグローバルな状態を管理するStoreです。ログイン情報やユーザー情報など、全体で使用するデータのみを扱い、ドメイン特化の状態はComponent Store
またはFeature Store
で管理します。
各種要素(actions
、effects
、reducers
、selectors
)はファイルを分割し、疎結合を保つ設計にしています。
サンプルコード
export const AuthActions = createActionGroup({
source: 'Auth',
events: {
'Login': props<{ user: User }>
'Logout': emptyProps()
}
});
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
})),
)
const selectUser = createSelector(
(state: AuthState) => state.user,
user => user
);
@Injectable()
export class AuthEffects {
logout$ = createEffect(() => this.actions$.pipe(
ofType('AuthActions.logout'),
exhaustMap(() => this.authService.logout()),
);
}
bootstrapApplication(AppComponent, {
providers: [
provideStore({ [authFeatureKey]: authReducer }),
]
});
最後に
Hubbleに参画する前は、NgRxに対して「使いづらい」「難しい」という印象を持っていました。しかし、近年のアップデートによってAngularとの親和性が向上し、以前と比べて格段に使いやすくなっています。たとえば、@ngrx/signals
など、Hubbleではまだ採用していない新機能も登場しており、今後の進化にも大いに期待しています。
この記事が、NgRxの魅力や可能性を知るきっかけになれば嬉しいです。ぜひ一度試してみてください!
明日は @ic_lifewoodさんです!
-
平日のみの投稿なので、投稿日は12日ですが9日目の記事としています。 ↩