【実務で使える】Angular+NgRx+Django開発を最速化する「screen.yaml起点」の自動生成戦略
はじめに
Webシステム開発では、以下のような課題がよく発生します。
-
画面とAPIの仕様がズレる
-
画面間のデータのつながりが不明確
-
UIレビューで何度も手戻りが発生する
-
ComponentにAPI呼び出しや状態管理が集中する
-
NgRxのAction / Effect / Reducer / Selectorを毎回手書きするのが大変
-
画面上のアクションが増えるほど実装パターンがバラつく
これらの問題は、設計の分断と定型コードの手書きによって発生します。
本記事では、
screen.yaml を起点に、Angular+NgRx構成の一覧画面・Service・Storeを自動生成する手法
を、実務レベルでそのまま使える形で解説します。
本記事の前提
本記事のテンプレートは、まず 一覧画面用 を想定しています。
具体的には、以下のような画面です。
初期表示で検索APIを呼ぶ
一覧データを表示する
検索・詳細遷移・削除など複数アクションを持つ
loading / error / empty を出し分ける
Componentから直接APIを呼ばない
本記事のゴール
screen.yamlを書く
↓
Angular Component が生成される
↓
NgRx Action / Effect / Reducer / Selector が生成される
↓
Service経由でAPIを呼び出す
↓
画面・API・状態管理・画面操作のズレを防ぐ
NgRx構成の全体像
今回の構成では、Componentから直接APIを呼び出しません。
Component
↓ dispatch
Action
↓
Effect
↓
Service
↓
API
↓
Effect
↓ dispatch success / failure
Reducer
↓
Selector
↓
Component
複数アクションの考え方
画面上の操作は、すべて同じ扱いにしない方がよいです。
判断基準は以下です。
APIを呼ぶ操作
→ NgRx Action / Effect / Service
画面遷移だけの操作
→ Router
画面内だけの表示切り替え
→ Componentローカル状態
複数画面で共有する状態
→ NgRx Store
アクション種別
本記事では、screen.yaml の actions.kind で処理を分けます。
kind: api
→ API呼び出しあり
kind: navigation
→ 画面遷移のみ
kind: local
→ Component内処理のみ
全体構成
tools/generator/
├─ generate.js
├─ package.json
├─ screens/
│ └─ user-list.yaml
└─ templates/
├─ model.ts.hbs
├─ service.ts.hbs
├─ component.ts.hbs
├─ component.html.hbs
├─ component.scss.hbs
├─ actions.ts.hbs
├─ effects.ts.hbs
├─ reducer.ts.hbs
└─ selectors.ts.hbs
package.json
{
"name": "screen-generator",
"version": "1.0.0",
"type": "commonjs",
"private": true,
"scripts": {
"generate": "node generate.js"
},
"dependencies": {
"handlebars": "^4.7.8",
"js-yaml": "^4.1.0"
}
}
Angular側で必要なNgRxパッケージ
Angularプロジェクト側では、NgRxを利用します。
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
screen.yaml(画面仕様)
screenId: user-list
screenName: ユーザー一覧
className: UserList
route: /users
outputDir: ../../src/app/generated/user-list
api:
search:
method: GET
path: /api/users/
responseType: UserListItem[]
delete:
method: DELETE
path: /api/users/{userId}/
responseType: void
model:
name: UserListItem
fields:
- name: userId
label: ユーザーID
type: string
display: true
- name: userName
label: ユーザー名
type: string
display: true
- name: departmentName
label: 部署名
type: string
display: true
actions:
- name: search
label: 検索
type: button
kind: api
method: GET
result: list
successMessage: 検索しました。
failureMessage: データの取得に失敗しました。
- name: detail
label: 詳細
type: link
kind: navigation
route: /users/{userId}
payload:
- userId
- name: delete
label: 削除
type: button
kind: api
method: DELETE
confirm: true
confirmMessage: 削除してもよろしいですか?
result: remove
successMessage: 削除しました。
failureMessage: 削除に失敗しました。
payload:
- userId
Generator本体
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const Handlebars = require('handlebars');
const screenFile = process.argv[2];
if (!screenFile) {
console.error('Usage: node generate.js screens/user-list.yaml');
process.exit(1);
}
const rootDir = __dirname;
const screenPath = path.resolve(rootDir, screenFile);
const screen = yaml.load(fs.readFileSync(screenPath, 'utf8'));
const outputDir = path.resolve(rootDir, screen.outputDir);
const storeDir = path.resolve(outputDir, 'store');
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(storeDir, { recursive: true });
const kebabToCamel = (value) =>
value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
const kebabToPascal = (value) =>
kebabToCamel(value).replace(/^./, (char) => char.toUpperCase());
const capitalize = (value) =>
value.replace(/^./, (char) => char.toUpperCase());
const isApiAction = (action) => action.kind === 'api';
const isNavigationAction = (action) => action.kind === 'navigation';
const isLocalAction = (action) => action.kind === 'local';
const buildActionEventName = (action) => capitalize(action.name);
const buildActionSuccessEventName = (action) => `${capitalize(action.name)} Success`;
const buildActionFailureEventName = (action) => `${capitalize(action.name)} Failure`;
const buildActionMethodName = (action) => kebabToCamel(action.name);
const buildActionSuccessMethodName = (action) => `${kebabToCamel(action.name)}Success`;
const buildActionFailureMethodName = (action) => `${kebabToCamel(action.name)}Failure`;
const buildPayloadType = (payload) => {
if (!payload || payload.length === 0) {
return '';
}
const fields = payload.map((name) => `${name}: string`).join('; ');
return `{ ${fields} }`;
};
Handlebars.registerHelper('eq', (a, b) => a === b);
Handlebars.registerHelper('not', (value) => !value);
Handlebars.registerHelper('and', (a, b) => a && b);
Handlebars.registerHelper('or', (a, b) => a || b);
Handlebars.registerHelper('pascal', kebabToPascal);
Handlebars.registerHelper('camel', kebabToCamel);
Handlebars.registerHelper('capitalize', capitalize);
Handlebars.registerHelper('isApiAction', isApiAction);
Handlebars.registerHelper('isNavigationAction', isNavigationAction);
Handlebars.registerHelper('isLocalAction', isLocalAction);
Handlebars.registerHelper('actionEventName', buildActionEventName);
Handlebars.registerHelper('actionSuccessEventName', buildActionSuccessEventName);
Handlebars.registerHelper('actionFailureEventName', buildActionFailureEventName);
Handlebars.registerHelper('actionMethodName', buildActionMethodName);
Handlebars.registerHelper('actionSuccessMethodName', buildActionSuccessMethodName);
Handlebars.registerHelper('actionFailureMethodName', buildActionFailureMethodName);
Handlebars.registerHelper('payloadType', buildPayloadType);
const apiActions = screen.actions.filter(isApiAction);
const navigationActions = screen.actions.filter(isNavigationAction);
const localActions = screen.actions.filter(isLocalAction);
const context = {
...screen,
componentSelector: `app-${screen.screenId}`,
componentClassName: `${screen.className}Component`,
serviceClassName: `${screen.className}Service`,
actionsClassName: `${screen.className}Actions`,
effectsClassName: `${screen.className}Effects`,
stateInterfaceName: `${screen.className}State`,
reducerName: `${kebabToCamel(screen.screenId)}Reducer`,
featureKeyName: `${kebabToCamel(screen.screenId)}FeatureKey`,
fileBaseName: screen.screenId,
displayFields: screen.model.fields.filter((field) => field.display),
primaryKey: screen.model.fields[0].name,
apiActions,
navigationActions,
localActions,
hasNavigationActions: navigationActions.length > 0,
hasApiActions: apiActions.length > 0,
};
const templates = [
{
template: 'model.ts.hbs',
output: `${screen.screenId}.model.ts`,
dir: outputDir,
},
{
template: 'service.ts.hbs',
output: `${screen.screenId}.service.ts`,
dir: outputDir,
},
{
template: 'component.ts.hbs',
output: `${screen.screenId}.component.ts`,
dir: outputDir,
},
{
template: 'component.html.hbs',
output: `${screen.screenId}.component.html`,
dir: outputDir,
},
{
template: 'component.scss.hbs',
output: `${screen.screenId}.component.scss`,
dir: outputDir,
},
{
template: 'actions.ts.hbs',
output: `${screen.screenId}.actions.ts`,
dir: storeDir,
},
{
template: 'effects.ts.hbs',
output: `${screen.screenId}.effects.ts`,
dir: storeDir,
},
{
template: 'reducer.ts.hbs',
output: `${screen.screenId}.reducer.ts`,
dir: storeDir,
},
{
template: 'selectors.ts.hbs',
output: `${screen.screenId}.selectors.ts`,
dir: storeDir,
},
];
for (const item of templates) {
const templatePath = path.resolve(rootDir, 'templates', item.template);
const outputPath = path.resolve(item.dir, item.output);
const templateSource = fs.readFileSync(templatePath, 'utf8');
const template = Handlebars.compile(templateSource);
const result = template(context);
fs.writeFileSync(outputPath, result, 'utf8');
console.log(`generated: ${outputPath}`);
}
テンプレート(Model)
export interface {{model.name}} {
{{#each model.fields}}
{{name}}: {{type}};
{{/each}}
}
テンプレート(Service)
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { {{model.name}} } from './{{fileBaseName}}.model';
@Injectable({
providedIn: 'root',
})
export class {{serviceClassName}} {
constructor(private readonly http: HttpClient) {}
{{#each apiActions}}
{{actionMethodName this}}({{#if payload}}{{#each payload}}{{this}}: string{{#unless @last}}, {{/unless}}{{/each}}{{/if}}): Observable<{{lookup ../api name "responseType"}}> {
{{#if payload}}
const path = {{#each payload}}{{#if @first}}{{/if}}{{/each}}`{{lookup ../api name "path"}}`;
return this.http.{{camel method}}<{{lookup ../api name "responseType"}}>(path);
{{else}}
return this.http.{{camel method}}<{{lookup ../api name "responseType"}}>('{{lookup ../api name "path"}}');
{{/if}}
}
{{/each}}
}
補足:Serviceテンプレートについて
上記の lookup を使うため、Generatorに以下のHelperを追加してください。
Handlebars.registerHelper('lookup', (object, key, property) => {
if (!object || !object[key]) {
return '';
}
return object[key][property];
});
また、DELETEなどで void を扱う場合、生成後のAngularコードでは以下のようになります。
delete(userId: string): Observable<void> {
const path = `/api/users/${userId}/`;
return this.http.delete<void>(path);
}
テンプレート(Component)
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
{{#if hasNavigationActions}}
import { Router } from '@angular/router';
{{/if}}
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { {{model.name}} } from './{{fileBaseName}}.model';
import { {{actionsClassName}} } from './store/{{fileBaseName}}.actions';
import {
select{{className}}Items,
select{{className}}Loading,
select{{className}}Error,
} from './store/{{fileBaseName}}.selectors';
@Component({
selector: '{{componentSelector}}',
standalone: true,
imports: [CommonModule],
templateUrl: './{{fileBaseName}}.component.html',
styleUrls: ['./{{fileBaseName}}.component.scss'],
})
export class {{componentClassName}} implements OnInit {
readonly items$: Observable<{{model.name}}[]> = this.store.select(select{{className}}Items);
readonly loading$: Observable<boolean> = this.store.select(select{{className}}Loading);
readonly error$: Observable<string | null> = this.store.select(select{{className}}Error);
constructor(
private readonly store: Store{{#if hasNavigationActions}},
private readonly router: Router{{/if}}
) {}
ngOnInit(): void {
{{#each apiActions}}
{{#if (eq name "search")}}
this.{{actionMethodName this}}();
{{/if}}
{{/each}}
}
{{#each actions}}
{{#if (isApiAction this)}}
{{actionMethodName this}}({{#if payload}}{{#each payload}}{{this}}: string{{#unless @last}}, {{/unless}}{{/each}}{{/if}}): void {
{{#if confirm}}
if (!window.confirm('{{confirmMessage}}')) {
return;
}
{{/if}}
this.store.dispatch(
{{../actionsClassName}}.{{actionMethodName this}}({{#if payload}}{ {{#each payload}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} }{{/if}})
);
}
{{/if}}
{{#if (isNavigationAction this)}}
{{actionMethodName this}}({{#if payload}}{{#each payload}}{{this}}: string{{#unless @last}}, {{/unless}}{{/each}}{{/if}}): void {
this.router.navigate([
{{#if payload}}
'{{route}}'.replace('{{"{"}}{{payload.[0]}}{{"}"}}', {{payload.[0]}})
{{else}}
'{{route}}'
{{/if}}
]);
}
{{/if}}
{{#if (isLocalAction this)}}
{{actionMethodName this}}(): void {
// TODO: 画面内だけで完結する処理を実装してください。
}
{{/if}}
{{/each}}
}
テンプレート(Component HTML)
<section class="screen" data-testid="{{screenId}}-screen">
<h1 data-testid="{{screenId}}-title">{{screenName}}</h1>
<div class="actions">
{{#each actions}}
{{#if (eq type "button")}}
{{#if (not payload)}}
<button
type="button"
data-testid="{{../screenId}}-{{name}}-button"
(click)="{{actionMethodName this}}()"
>
{{label}}
</button>
{{/if}}
{{/if}}
{{/each}}
</div>
@if (loading$ | async) {
<p data-testid="{{screenId}}-loading">読み込み中...</p>
}
@if (error$ | async; as error) {
<p class="error" data-testid="{{screenId}}-error">
{{ '{{ error }}' }}
</p>
}
@if (items$ | async; as items) {
@if (!(loading$ | async) && !((error$ | async)) && items.length === 0) {
<p data-testid="{{screenId}}-empty">データがありません。</p>
}
@if (!(loading$ | async) && !((error$ | async)) && items.length > 0) {
<table data-testid="{{screenId}}-table">
<thead>
<tr>
{{#each displayFields}}
<th>{{label}}</th>
{{/each}}
<th>操作</th>
</tr>
</thead>
<tbody>
@for (item of items; track item.{{primaryKey}}) {
<tr data-testid="{{screenId}}-row">
{{#each displayFields}}
<td data-testid="{{../screenId}}-{{name}}">
{{ '{{ item.' }}{{name}}{{ ' }}' }}
</td>
{{/each}}
<td>
{{#each actions}}
{{#if payload}}
{{#if (eq type "button")}}
<button
type="button"
data-testid="{{../screenId}}-{{name}}-button"
(click)="{{actionMethodName this}}({{#each payload}}item.{{this}}{{#unless @last}}, {{/unless}}{{/each}})"
>
{{label}}
</button>
{{/if}}
{{#if (eq type "link")}}
<button
type="button"
class="link-button"
data-testid="{{../screenId}}-{{name}}-link"
(click)="{{actionMethodName this}}({{#each payload}}item.{{this}}{{#unless @last}}, {{/unless}}{{/each}})"
>
{{label}}
</button>
{{/if}}
{{/if}}
{{/each}}
</td>
</tr>
}
</tbody>
</table>
}
}
</section>
テンプレート(Component SCSS)
.screen {
padding: 16px;
}
.actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.error {
color: #b00020;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 8px;
border: 1px solid #ddd;
text-align: left;
}
.link-button {
padding: 0;
border: none;
background: transparent;
color: #0645ad;
text-decoration: underline;
cursor: pointer;
}
テンプレート(Actions)
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { {{model.name}} } from '../{{fileBaseName}}.model';
export const {{actionsClassName}} = createActionGroup({
source: '{{screenName}}',
events: {
{{#each apiActions}}
{{#if payload}}
'{{actionEventName this}}': props<{{payloadType payload}}>(),
{{else}}
'{{actionEventName this}}': emptyProps(),
{{/if}}
{{#if (eq result "list")}}
'{{actionSuccessEventName this}}': props<{ items: {{../model.name}}[] }>(),
{{else}}
{{#if payload}}
'{{actionSuccessEventName this}}': props<{{payloadType payload}}>(),
{{else}}
'{{actionSuccessEventName this}}': emptyProps(),
{{/if}}
{{/if}}
'{{actionFailureEventName this}}': props<{ error: string }>(),
{{/each}}
},
});
テンプレート(Effects)
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { {{serviceClassName}} } from '../{{fileBaseName}}.service';
import { {{actionsClassName}} } from './{{fileBaseName}}.actions';
@Injectable()
export class {{effectsClassName}} {
{{#each apiActions}}
readonly {{actionMethodName this}}$ = createEffect(() =>
this.actions$.pipe(
ofType({{../actionsClassName}}.{{actionMethodName this}}),
switchMap((action) =>
this.service.{{actionMethodName this}}({{#if payload}}{{#each payload}}action.{{this}}{{#unless @last}}, {{/unless}}{{/each}}{{/if}}).pipe(
{{#if (eq result "list")}}
map((items) => {{../actionsClassName}}.{{actionSuccessMethodName this}}({ items })),
{{else}}
{{#if payload}}
map(() =>
{{../actionsClassName}}.{{actionSuccessMethodName this}}({
{{#each payload}}
{{this}}: action.{{this}},
{{/each}}
})
),
{{else}}
map(() => {{../actionsClassName}}.{{actionSuccessMethodName this}}()),
{{/if}}
{{/if}}
catchError(() =>
of(
{{../actionsClassName}}.{{actionFailureMethodName this}}({
error: '{{failureMessage}}',
})
)
)
)
)
)
);
{{/each}}
constructor(
private readonly actions$: Actions,
private readonly service: {{serviceClassName}}
) {}
}
テンプレート(Reducer)
import { createReducer, on } from '@ngrx/store';
import { {{model.name}} } from '../{{fileBaseName}}.model';
import { {{actionsClassName}} } from './{{fileBaseName}}.actions';
export const {{featureKeyName}} = '{{camel screenId}}';
export interface {{stateInterfaceName}} {
items: {{model.name}}[];
loading: boolean;
error: string | null;
}
export const initial{{stateInterfaceName}}: {{stateInterfaceName}} = {
items: [],
loading: false,
error: null,
};
export const {{reducerName}} = createReducer(
initial{{stateInterfaceName}},
{{#each apiActions}}
on({{../actionsClassName}}.{{actionMethodName this}}, (state): {{../stateInterfaceName}} => ({
...state,
loading: true,
error: null,
})),
{{#if (eq result "list")}}
on({{../actionsClassName}}.{{actionSuccessMethodName this}}, (state, { items }): {{../stateInterfaceName}} => ({
...state,
items,
loading: false,
error: null,
})),
{{else}}
{{#if (eq result "remove")}}
on({{../actionsClassName}}.{{actionSuccessMethodName this}}, (state, { {{payload.[0]}} }): {{../stateInterfaceName}} => ({
...state,
items: state.items.filter((item) => item.{{../primaryKey}} !== {{payload.[0]}}),
loading: false,
error: null,
})),
{{else}}
on({{../actionsClassName}}.{{actionSuccessMethodName this}}, (state): {{../stateInterfaceName}} => ({
...state,
loading: false,
error: null,
})),
{{/if}}
{{/if}}
on({{../actionsClassName}}.{{actionFailureMethodName this}}, (state, { error }): {{../stateInterfaceName}} => ({
...state,
loading: false,
error,
})){{#unless @last}},{{/unless}}
{{/each}}
);
テンプレート(Selectors)
import { createFeatureSelector, createSelector } from '@ngrx/store';
import {
{{featureKeyName}},
{{stateInterfaceName}},
} from './{{fileBaseName}}.reducer';
export const select{{className}}State =
createFeatureSelector<{{stateInterfaceName}}>({{featureKeyName}});
export const select{{className}}Items = createSelector(
select{{className}}State,
(state) => state.items
);
export const select{{className}}Loading = createSelector(
select{{className}}State,
(state) => state.loading
);
export const select{{className}}Error = createSelector(
select{{className}}State,
(state) => state.error
);
実行方法
cd tools/generator
npm install
npm run generate -- screens/user-list.yaml
生成結果
src/app/generated/user-list/
├─ user-list.model.ts
├─ user-list.service.ts
├─ user-list.component.ts
├─ user-list.component.html
├─ user-list.component.scss
└─ store/
├─ user-list.actions.ts
├─ user-list.effects.ts
├─ user-list.reducer.ts
└─ user-list.selectors.ts
生成後のコード例
上記テンプレートを使うと、以下のようなコードが生成されます。
user-list.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { UserListItem } from './user-list.model';
@Injectable({
providedIn: 'root',
})
export class UserListService {
constructor(private readonly http: HttpClient) {}
search(): Observable<UserListItem[]> {
return this.http.get<UserListItem[]>('/api/users/');
}
delete(userId: string): Observable<void> {
const path = `/api/users/${userId}/`;
return this.http.delete<void>(path);
}
}
user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { UserListItem } from './user-list.model';
import { UserListActions } from './store/user-list.actions';
import {
selectUserListItems,
selectUserListLoading,
selectUserListError,
} from './store/user-list.selectors';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule],
templateUrl: './user-list.component.html',
styleUrls: ['./user-list.component.scss'],
})
export class UserListComponent implements OnInit {
readonly items$: Observable<UserListItem[]> = this.store.select(selectUserListItems);
readonly loading$: Observable<boolean> = this.store.select(selectUserListLoading);
readonly error$: Observable<string | null> = this.store.select(selectUserListError);
constructor(
private readonly store: Store,
private readonly router: Router
) {}
ngOnInit(): void {
this.search();
}
search(): void {
this.store.dispatch(UserListActions.search());
}
detail(userId: string): void {
this.router.navigate([
'/users/{userId}'.replace('{userId}', userId)
]);
}
delete(userId: string): void {
if (!window.confirm('削除してもよろしいですか?')) {
return;
}
this.store.dispatch(
UserListActions.delete({ userId })
);
}
}
user-list.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { UserListItem } from '../user-list.model';
export const UserListActions = createActionGroup({
source: 'ユーザー一覧',
events: {
Search: emptyProps(),
'Search Success': props<{ items: UserListItem[] }>(),
'Search Failure': props<{ error: string }>(),
Delete: props<{ userId: string }>(),
'Delete Success': props<{ userId: string }>(),
'Delete Failure': props<{ error: string }>(),
},
});
user-list.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { UserListService } from '../user-list.service';
import { UserListActions } from './user-list.actions';
@Injectable()
export class UserListEffects {
readonly search$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListActions.search),
switchMap(() =>
this.service.search().pipe(
map((items) => UserListActions.searchSuccess({ items })),
catchError(() =>
of(
UserListActions.searchFailure({
error: 'データの取得に失敗しました。',
})
)
)
)
)
)
);
readonly delete$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListActions.delete),
switchMap((action) =>
this.service.delete(action.userId).pipe(
map(() =>
UserListActions.deleteSuccess({
userId: action.userId,
})
),
catchError(() =>
of(
UserListActions.deleteFailure({
error: '削除に失敗しました。',
})
)
)
)
)
)
);
constructor(
private readonly actions$: Actions,
private readonly service: UserListService
) {}
}
user-list.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { UserListItem } from '../user-list.model';
import { UserListActions } from './user-list.actions';
export const userListFeatureKey = 'userList';
export interface UserListState {
items: UserListItem[];
loading: boolean;
error: string | null;
}
export const initialUserListState: UserListState = {
items: [],
loading: false,
error: null,
};
export const userListReducer = createReducer(
initialUserListState,
on(UserListActions.search, (state): UserListState => ({
...state,
loading: true,
error: null,
})),
on(UserListActions.searchSuccess, (state, { items }): UserListState => ({
...state,
items,
loading: false,
error: null,
})),
on(UserListActions.searchFailure, (state, { error }): UserListState => ({
...state,
loading: false,
error,
})),
on(UserListActions.delete, (state): UserListState => ({
...state,
loading: true,
error: null,
})),
on(UserListActions.deleteSuccess, (state, { userId }): UserListState => ({
...state,
items: state.items.filter((item) => item.userId !== userId),
loading: false,
error: null,
})),
on(UserListActions.deleteFailure, (state, { error }): UserListState => ({
...state,
loading: false,
error,
}))
);
user-list.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import {
userListFeatureKey,
UserListState,
} from './user-list.reducer';
export const selectUserListState =
createFeatureSelector<UserListState>(userListFeatureKey);
export const selectUserListItems = createSelector(
selectUserListState,
(state) => state.items
);
export const selectUserListLoading = createSelector(
selectUserListState,
(state) => state.loading
);
export const selectUserListError = createSelector(
selectUserListState,
(state) => state.error
);
Angularルーティング例
import { Routes } from '@angular/router';
import { UserListComponent } from './generated/user-list/user-list.component';
export const routes: Routes = [
{
path: 'users',
component: UserListComponent,
},
];
NgRx Store / Effects の登録例
Standalone構成の場合、app.config.ts などで登録します。
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { routes } from './app.routes';
import {
userListFeatureKey,
userListReducer,
} from './generated/user-list/store/user-list.reducer';
import { UserListEffects } from './generated/user-list/store/user-list.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideRouter(routes),
provideStore({
[userListFeatureKey]: userListReducer,
}),
provideEffects([UserListEffects]),
provideStoreDevtools({
maxAge: 25,
}),
],
};
NgRx構成で生成される処理の流れ
1. UserListComponent が ngOnInit で search() を呼ぶ
2. search() が UserListActions.search() を dispatch
3. UserListEffects が search Action を検知
4. UserListService.search() を呼び出す
5. API通信に成功したら searchSuccess を dispatch
6. 失敗したら searchFailure を dispatch
7. Reducer が state を更新
8. Selector 経由で Component に反映
削除の場合は以下です。
1. 削除ボタンを押す
2. confirmで確認する
3. UserListActions.delete({ userId }) を dispatch
4. UserListEffects が delete Action を検知
5. UserListService.delete(userId) を呼び出す
6. 成功したら deleteSuccess({ userId }) を dispatch
7. Reducer が一覧から対象行を削除する
8. Selector 経由で Component に反映
詳細遷移の場合は以下です。
1. 詳細ボタンを押す
2. Component内で Router.navigate を呼ぶ
3. APIは呼ばない
4. Storeも更新しない
運用ルール
generated は手修正しない
手書きコードは features に置く
画面仕様の変更は screen.yaml を先に直す
ComponentからServiceを直接呼ばない
API呼び出しは Effect から行う
画面表示は Selector 経由の Observable を使う
画面遷移だけの操作は Router を使う
画面内だけの表示切り替えは Component ローカル状態にする
まとめ
✔ screen.yamlで画面仕様を統一
✔ 一覧画面テンプレートとして使う
✔ 複数アクションは actions 配列で管理する
✔ API操作は Action / Effect / Service に流す
✔ 画面遷移は Router に任せる
✔ ComponentはActionをdispatchするだけにする
✔ Reducerで状態を更新する
✔ Selectorで画面に状態を渡す
✔ UI・API・状態管理・操作パターンのズレを防止する
最後に
この手法の本質は「コード生成」ではありません。
設計を構造化し、実装パターンを統一すること
NgRxはファイル数が増えやすい一方で、構成を統一できれば保守性が大きく上がります。
そのため、screen.yaml を起点にAction / Effect / Reducer / Selectorまで生成する方式は、チーム開発と相性が良いです。