Zustand でネストした状態を扱う:スキーマ設計と更新ロジックの整理術

Zustand を使った開発では、アプリケーションが複雑になるにつれて、ネストした状態の管理が避けられない課題となります。シンプルなフラット構造から始まったストアも、ユーザー情報、設定項目、フォームデータなどが組み合わさると、多層的な構造へと発展していくでしょう。
しかし、ネストした状態の管理は一筋縄ではいきません。適切な設計なしに進めると、状態の更新が複雑になり、パフォーマンスの問題や型安全性の課題に直面することになります。
本記事では、Zustand でネストした状態を効率的に扱うためのスキーマ設計の考え方と、実用的な更新ロジックの整理術をご紹介します。実際のコード例を交えながら、現場で使える実践的なテクニックを学んでいきましょう。
背景
フラットな状態管理の限界
多くの Zustand プロジェクトは、以下のようなシンプルなフラット構造から始まります。
typescriptinterface AppState {
count: number;
user: string;
theme: string;
increment: () => void;
setUser: (name: string) => void;
setTheme: (theme: string) => void;
}
このアプローチは小規模なアプリケーションでは十分機能しますが、機能が拡張されるにつれて限界が見えてきます。ユーザー情報が名前だけでなく、プロファイル画像、設定、権限なども含むようになると、フラット構造では管理が困難になってしまうのです。
typescript// フラット構造の限界例
interface AppState {
userName: string;
userEmail: string;
userAvatar: string;
userTheme: string;
userNotifications: boolean;
userPrivacy: string;
// ... 他にも多数のuser関連プロパティ
}
複雑なアプリケーションでのネスト構造の必要性
現代の Web アプリケーションでは、以下のような複雑な状態管理が求められます。
# | シーン | 必要な状態構造 |
---|---|---|
1 | EC サイト | 商品カテゴリ、カート内容、ユーザー設定の階層管理 |
2 | ダッシュボード | ウィジェット設定、レイアウト情報、フィルター条件の組み合わせ |
3 | フォームアプリ | セクション毎の入力値、バリデーション状態、表示制御フラグ |
これらのケースでは、関連する状態をグループ化し、階層的に管理することで、コードの可読性と保守性が大幅に向上します。
typescript// ネスト構造での改善例
interface AppState {
user: {
profile: {
name: string;
email: string;
avatar: string;
};
settings: {
theme: string;
notifications: boolean;
privacy: string;
};
};
cart: {
items: CartItem[];
total: number;
};
}
課題
ネストした状態更新の複雑さ
ネストした状態を扱う際の最大の課題は、更新処理の複雑さです。React の状態更新原則である「イミュータブルな更新」を遵守しながら、深い階層の値を変更するには、多くのボイラープレートコードが必要になります。
typescript// 問題のあるネスト状態更新例
const updateUserTheme = (newTheme: string) => {
set((state) => ({
...state,
user: {
...state.user,
settings: {
...state.user.settings,
theme: newTheme,
},
},
}));
};
このようなコードは、階層が深くなるほど可読性が悪化し、エラーの温床となります。
パフォーマンスの問題
ネストした状態の不適切な更新は、予期しない再レンダリングを引き起こします。特に、オブジェクト全体を新しく作成してしまうと、参照の変更により関連するコンポーネントすべてが再レンダリングされてしまうのです。
typescript// パフォーマンスに問題のある例
const badUpdate = (userId: string, newName: string) => {
set((state) => ({
...state,
users: state.users.map((user) =>
user.id === userId ? { ...user, name: newName } : user
),
}));
};
この例では、特定のユーザーの名前を変更するだけなのに、users 配列全体が新しく作成され、すべてのユーザーコンポーネントが再レンダリングされる可能性があります。
型安全性の確保の困難さ
TypeScript を使用していても、ネストした状態の型安全性を確保するのは簡単ではありません。深い階層でのオプショナルプロパティや、動的に決まるキーを持つオブジェクトの扱いでは、型エラーが発生しやすくなります。
typescript// 型安全性の課題例
interface UserState {
users: {
[userId: string]: {
profile?: {
name?: string;
settings?: {
theme?: string;
};
};
};
};
}
// このような更新では型エラーが発生しやすい
const updateUserTheme = (userId: string, theme: string) => {
set((state) => ({
...state,
users: {
...state.users,
[userId]: {
...state.users[userId],
profile: {
...state.users[userId]?.profile, // 型エラーの可能性
settings: {
...state.users[userId]?.profile?.settings,
theme,
},
},
},
},
}));
};
解決策
浅い更新 vs 深い更新の使い分け
ネストした状態を効率的に管理するために、まず「浅い更新」と「深い更新」の使い分けを理解することが重要です。
浅い更新は、オブジェクトの第一階層のプロパティのみを変更する方法です。パフォーマンスが良く、予測しやすい動作をしますが、深い階層の変更には向きません。
typescript// 浅い更新の例
interface AppState {
userProfile: UserProfile;
appSettings: AppSettings;
updateUserProfile: (profile: UserProfile) => void;
updateAppSettings: (settings: AppSettings) => void;
}
const useAppStore = create<AppState>((set) => ({
userProfile: {
name: '',
email: '',
},
appSettings: {
theme: 'light',
language: 'ja',
},
// オブジェクト全体を置き換える浅い更新
updateUserProfile: (profile) =>
set({ userProfile: profile }),
updateAppSettings: (settings) =>
set({ appSettings: settings }),
}));
深い更新は、ネストした階層の特定のプロパティのみを変更する方法です。より細かい制御が可能ですが、実装が複雑になります。
typescript// 深い更新の例
interface AppState {
user: {
profile: {
name: string;
email: string;
};
settings: {
theme: string;
notifications: boolean;
};
};
updateUserName: (name: string) => void;
updateUserTheme: (theme: string) => void;
}
const useAppStore = create<AppState>((set) => ({
user: {
profile: { name: '', email: '' },
settings: { theme: 'light', notifications: true },
},
// 深い更新:名前のみを変更
updateUserName: (name) =>
set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
name,
},
},
})),
// 深い更新:テーマのみを変更
updateUserTheme: (theme) =>
set((state) => ({
user: {
...state.user,
settings: {
...state.user.settings,
theme,
},
},
})),
}));
immer ライブラリとの連携
深い更新の複雑さを解決するために、immer ライブラリとの連携が効果的です。immer を使用すると、ミュータブルな書き方でイミュータブルな更新を実現できます。
まず、必要な依存関係をインストールします。
bashyarn add immer
yarn add -D @types/immer
次に、immer を活用したストアを作成します。
typescriptimport { create } from 'zustand';
import { produce } from 'immer';
interface NestedState {
user: {
profile: {
name: string;
email: string;
address: {
prefecture: string;
city: string;
zipCode: string;
};
};
settings: {
theme: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
};
};
updateUserName: (name: string) => void;
updateUserCity: (city: string) => void;
toggleEmailNotification: () => void;
}
const useNestedStore = create<NestedState>((set) => ({
user: {
profile: {
name: '',
email: '',
address: {
prefecture: '',
city: '',
zipCode: '',
},
},
settings: {
theme: 'light',
notifications: {
email: true,
push: false,
sms: false,
},
},
},
// immer を使った簡潔な深い更新
updateUserName: (name) =>
set(
produce((state) => {
state.user.profile.name = name;
})
),
updateUserCity: (city) =>
set(
produce((state) => {
state.user.profile.address.city = city;
})
),
toggleEmailNotification: () =>
set(
produce((state) => {
state.user.settings.notifications.email =
!state.user.settings.notifications.email;
})
),
}));
スライスパターンによる分割管理
大規模なアプリケーションでは、関連する状態と操作をスライス(slice)として分割管理することで、コードの整理と保守性の向上を図れます。
typescript// ユーザー関連のスライス
interface UserSlice {
user: {
profile: UserProfile;
settings: UserSettings;
};
updateUserProfile: (
profile: Partial<UserProfile>
) => void;
updateUserSettings: (
settings: Partial<UserSettings>
) => void;
}
const createUserSlice = (set: any): UserSlice => ({
user: {
profile: { name: '', email: '' },
settings: { theme: 'light', notifications: true },
},
updateUserProfile: (profile) =>
set(
produce((state: any) => {
Object.assign(state.user.profile, profile);
})
),
updateUserSettings: (settings) =>
set(
produce((state: any) => {
Object.assign(state.user.settings, settings);
})
),
});
// ショッピングカート関連のスライス
interface CartSlice {
cart: {
items: CartItem[];
total: number;
};
addToCart: (item: CartItem) => void;
removeFromCart: (itemId: string) => void;
updateQuantity: (
itemId: string,
quantity: number
) => void;
}
const createCartSlice = (set: any): CartSlice => ({
cart: {
items: [],
total: 0,
},
addToCart: (item) =>
set(
produce((state: any) => {
state.cart.items.push(item);
state.cart.total += item.price * item.quantity;
})
),
removeFromCart: (itemId) =>
set(
produce((state: any) => {
const index = state.cart.items.findIndex(
(item: CartItem) => item.id === itemId
);
if (index !== -1) {
const item = state.cart.items[index];
state.cart.total -= item.price * item.quantity;
state.cart.items.splice(index, 1);
}
})
),
updateQuantity: (itemId, quantity) =>
set(
produce((state: any) => {
const item = state.cart.items.find(
(item: CartItem) => item.id === itemId
);
if (item) {
state.cart.total +=
(quantity - item.quantity) * item.price;
item.quantity = quantity;
}
})
),
});
// スライスを結合したメインストア
type AppState = UserSlice & CartSlice;
const useAppStore = create<AppState>((set, get) => ({
...createUserSlice(set),
...createCartSlice(set),
}));
この分割アプローチにより、各機能の責任が明確になり、テストやデバッグが容易になります。
具体例
ユーザープロファイル管理
実際のユーザープロファイル管理システムを例に、ネストした状態の実装を見てみましょう。
typescriptinterface UserProfile {
id: string;
personalInfo: {
firstName: string;
lastName: string;
email: string;
phone: string;
birthDate: Date | null;
};
address: {
country: string;
prefecture: string;
city: string;
street: string;
zipCode: string;
};
preferences: {
language: string;
timezone: string;
theme: 'light' | 'dark' | 'auto';
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
};
subscription: {
plan: 'free' | 'premium' | 'enterprise';
validUntil: Date | null;
features: string[];
};
}
interface UserProfileState {
profile: UserProfile | null;
isLoading: boolean;
error: string | null;
// プロファイル全体の操作
setProfile: (profile: UserProfile) => void;
clearProfile: () => void;
// 個人情報の更新
updatePersonalInfo: (
info: Partial<UserProfile['personalInfo']>
) => void;
// 住所情報の更新
updateAddress: (
address: Partial<UserProfile['address']>
) => void;
// 設定の更新
updatePreferences: (
preferences: Partial<UserProfile['preferences']>
) => void;
updateNotificationSettings: (
notifications: Partial<
UserProfile['preferences']['notifications']
>
) => void;
// サブスクリプション管理
updateSubscription: (
subscription: Partial<UserProfile['subscription']>
) => void;
}
const useUserProfileStore = create<UserProfileState>(
(set) => ({
profile: null,
isLoading: false,
error: null,
setProfile: (profile) => set({ profile, error: null }),
clearProfile: () => set({ profile: null, error: null }),
updatePersonalInfo: (info) =>
set(
produce((state) => {
if (state.profile) {
Object.assign(state.profile.personalInfo, info);
}
})
),
updateAddress: (address) =>
set(
produce((state) => {
if (state.profile) {
Object.assign(state.profile.address, address);
}
})
),
updatePreferences: (preferences) =>
set(
produce((state) => {
if (state.profile) {
Object.assign(
state.profile.preferences,
preferences
);
}
})
),
updateNotificationSettings: (notifications) =>
set(
produce((state) => {
if (state.profile) {
Object.assign(
state.profile.preferences.notifications,
notifications
);
}
})
),
updateSubscription: (subscription) =>
set(
produce((state) => {
if (state.profile) {
Object.assign(
state.profile.subscription,
subscription
);
}
})
),
})
);
コンポーネントでの使用例:
typescriptimport React from 'react';
import { useUserProfileStore } from './userProfileStore';
const UserProfileForm: React.FC = () => {
const {
profile,
updatePersonalInfo,
updateAddress,
updateNotificationSettings,
} = useUserProfileStore();
if (!profile)
return <div>プロファイルが読み込まれていません</div>;
return (
<div>
<section>
<h2>個人情報</h2>
<input
value={profile.personalInfo.firstName}
onChange={(e) =>
updatePersonalInfo({
firstName: e.target.value,
})
}
placeholder='名前'
/>
<input
value={profile.personalInfo.email}
onChange={(e) =>
updatePersonalInfo({ email: e.target.value })
}
placeholder='メールアドレス'
/>
</section>
<section>
<h2>住所</h2>
<input
value={profile.address.prefecture}
onChange={(e) =>
updateAddress({ prefecture: e.target.value })
}
placeholder='都道府県'
/>
<input
value={profile.address.city}
onChange={(e) =>
updateAddress({ city: e.target.value })
}
placeholder='市区町村'
/>
</section>
<section>
<h2>通知設定</h2>
<label>
<input
type='checkbox'
checked={
profile.preferences.notifications.email
}
onChange={(e) =>
updateNotificationSettings({
email: e.target.checked,
})
}
/>
メール通知
</label>
<label>
<input
type='checkbox'
checked={profile.preferences.notifications.push}
onChange={(e) =>
updateNotificationSettings({
push: e.target.checked,
})
}
/>
プッシュ通知
</label>
</section>
</div>
);
};
ショッピングカート
EC サイトのショッピングカート機能では、商品の追加、削除、数量変更などの複雑な操作が必要です。
typescriptinterface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
options: {
size?: string;
color?: string;
customization?: Record<string, any>;
};
discount?: {
type: 'percentage' | 'fixed';
value: number;
};
}
interface ShippingInfo {
method: 'standard' | 'express' | 'overnight';
cost: number;
estimatedDays: number;
}
interface CartState {
items: CartItem[];
shipping: ShippingInfo | null;
promoCode: string | null;
totals: {
subtotal: number;
discount: number;
shipping: number;
tax: number;
total: number;
};
// カート操作
addItem: (item: Omit<CartItem, 'id'>) => void;
removeItem: (itemId: string) => void;
updateQuantity: (
itemId: string,
quantity: number
) => void;
updateItemOptions: (
itemId: string,
options: Partial<CartItem['options']>
) => void;
clearCart: () => void;
// 配送・決済
setShipping: (shipping: ShippingInfo) => void;
applyPromoCode: (code: string) => void;
removePromoCode: () => void;
// 計算
calculateTotals: () => void;
}
const useCartStore = create<CartState>((set, get) => ({
items: [],
shipping: null,
promoCode: null,
totals: {
subtotal: 0,
discount: 0,
shipping: 0,
tax: 0,
total: 0,
},
addItem: (newItem) =>
set(
produce((state) => {
// 既存のアイテムかチェック(商品ID + オプションで判定)
const existingItemIndex = state.items.findIndex(
(item) =>
item.productId === newItem.productId &&
JSON.stringify(item.options) ===
JSON.stringify(newItem.options)
);
if (existingItemIndex >= 0) {
// 既存アイテムの数量を増加
state.items[existingItemIndex].quantity +=
newItem.quantity;
} else {
// 新規アイテムを追加
state.items.push({
...newItem,
id: `${newItem.productId}-${Date.now()}`,
});
}
})
),
removeItem: (itemId) =>
set(
produce((state) => {
const index = state.items.findIndex(
(item) => item.id === itemId
);
if (index >= 0) {
state.items.splice(index, 1);
}
})
),
updateQuantity: (itemId, quantity) =>
set(
produce((state) => {
const item = state.items.find(
(item) => item.id === itemId
);
if (item && quantity > 0) {
item.quantity = quantity;
}
})
),
updateItemOptions: (itemId, options) =>
set(
produce((state) => {
const item = state.items.find(
(item) => item.id === itemId
);
if (item) {
Object.assign(item.options, options);
}
})
),
clearCart: () =>
set({
items: [],
promoCode: null,
totals: {
subtotal: 0,
discount: 0,
shipping: 0,
tax: 0,
total: 0,
},
}),
setShipping: (shipping) => set({ shipping }),
applyPromoCode: (code) => set({ promoCode: code }),
removePromoCode: () => set({ promoCode: null }),
calculateTotals: () =>
set(
produce((state) => {
// 小計の計算
state.totals.subtotal = state.items.reduce(
(sum, item) => {
let itemTotal = item.price * item.quantity;
// アイテム固有の割引を適用
if (item.discount) {
if (item.discount.type === 'percentage') {
itemTotal *=
(100 - item.discount.value) / 100;
} else {
itemTotal -= item.discount.value;
}
}
return sum + itemTotal;
},
0
);
// プロモコードによる割引(簡略化)
state.totals.discount = state.promoCode
? state.totals.subtotal * 0.1
: 0;
// 配送料
state.totals.shipping = state.shipping?.cost || 0;
// 税金(消費税10%として計算)
const taxableAmount =
state.totals.subtotal -
state.totals.discount +
state.totals.shipping;
state.totals.tax = taxableAmount * 0.1;
// 合計
state.totals.total =
state.totals.subtotal -
state.totals.discount +
state.totals.shipping +
state.totals.tax;
})
),
}));
フォーム状態管理
複雑なフォームでは、入力値、バリデーション状態、表示制御など多様な状態を管理する必要があります。
typescriptinterface FieldState {
value: any;
error: string | null;
touched: boolean;
dirty: boolean;
}
interface FormSection {
fields: Record<string, FieldState>;
isValid: boolean;
isVisible: boolean;
}
interface MultiStepFormState {
currentStep: number;
sections: {
personal: FormSection;
contact: FormSection;
preferences: FormSection;
confirmation: FormSection;
};
// フォーム操作
nextStep: () => void;
prevStep: () => void;
goToStep: (step: number) => void;
// フィールド操作
setFieldValue: (
section: keyof MultiStepFormState['sections'],
fieldName: string,
value: any
) => void;
setFieldError: (
section: keyof MultiStepFormState['sections'],
fieldName: string,
error: string | null
) => void;
touchField: (
section: keyof MultiStepFormState['sections'],
fieldName: string
) => void;
// セクション操作
validateSection: (
section: keyof MultiStepFormState['sections']
) => boolean;
resetSection: (
section: keyof MultiStepFormState['sections']
) => void;
// フォーム全体
validateForm: () => boolean;
resetForm: () => void;
submitForm: () => Promise<void>;
}
const initialFieldState: FieldState = {
value: '',
error: null,
touched: false,
dirty: false,
};
const createFormSection = (
fields: string[]
): FormSection => ({
fields: fields.reduce((acc, fieldName) => {
acc[fieldName] = { ...initialFieldState };
return acc;
}, {} as Record<string, FieldState>),
isValid: false,
isVisible: true,
});
const useMultiStepFormStore = create<MultiStepFormState>(
(set, get) => ({
currentStep: 0,
sections: {
personal: createFormSection([
'firstName',
'lastName',
'birthDate',
]),
contact: createFormSection([
'email',
'phone',
'address',
]),
preferences: createFormSection([
'theme',
'notifications',
'language',
]),
confirmation: createFormSection([]),
},
nextStep: () =>
set((state) => ({
currentStep: Math.min(
state.currentStep + 1,
Object.keys(state.sections).length - 1
),
})),
prevStep: () =>
set((state) => ({
currentStep: Math.max(state.currentStep - 1, 0),
})),
goToStep: (step) =>
set((state) => ({
currentStep: Math.max(
0,
Math.min(
step,
Object.keys(state.sections).length - 1
)
),
})),
setFieldValue: (sectionName, fieldName, value) =>
set(
produce((state) => {
const field =
state.sections[sectionName].fields[fieldName];
if (field) {
field.value = value;
field.dirty = true;
field.error = null; // 値が変更されたらエラーをクリア
}
})
),
setFieldError: (sectionName, fieldName, error) =>
set(
produce((state) => {
const field =
state.sections[sectionName].fields[fieldName];
if (field) {
field.error = error;
}
})
),
touchField: (sectionName, fieldName) =>
set(
produce((state) => {
const field =
state.sections[sectionName].fields[fieldName];
if (field) {
field.touched = true;
}
})
),
validateSection: (sectionName) => {
const { sections } = get();
const section = sections[sectionName];
// 簡単なバリデーション例
const isValid = Object.values(section.fields).every(
(field) =>
field.value !== '' && field.error === null
);
set(
produce((state) => {
state.sections[sectionName].isValid = isValid;
})
);
return isValid;
},
resetSection: (sectionName) =>
set(
produce((state) => {
const section = state.sections[sectionName];
Object.keys(section.fields).forEach(
(fieldName) => {
section.fields[fieldName] = {
...initialFieldState,
};
}
);
section.isValid = false;
})
),
validateForm: () => {
const { sections } = get();
return Object.keys(sections).every((sectionName) =>
get().validateSection(
sectionName as keyof MultiStepFormState['sections']
)
);
},
resetForm: () =>
set((state) => ({
currentStep: 0,
sections: {
personal: createFormSection([
'firstName',
'lastName',
'birthDate',
]),
contact: createFormSection([
'email',
'phone',
'address',
]),
preferences: createFormSection([
'theme',
'notifications',
'language',
]),
confirmation: createFormSection([]),
},
})),
submitForm: async () => {
const isValid = get().validateForm();
if (!isValid) {
throw new Error('フォームに入力エラーがあります');
}
// 実際の送信処理
try {
const formData = get().sections;
// API呼び出しなど
console.log('フォームデータを送信:', formData);
} catch (error) {
console.error('送信エラー:', error);
throw error;
}
},
})
);
コンポーネントでの使用例:
typescriptimport React from 'react';
import { useMultiStepFormStore } from './multiStepFormStore';
const PersonalInfoStep: React.FC = () => {
const {
sections,
setFieldValue,
touchField,
validateSection,
} = useMultiStepFormStore();
const personalSection = sections.personal;
const handleFieldChange = (
fieldName: string,
value: string
) => {
setFieldValue('personal', fieldName, value);
};
const handleFieldBlur = (fieldName: string) => {
touchField('personal', fieldName);
validateSection('personal');
};
return (
<div>
<h2>個人情報の入力</h2>
<div>
<label>名前</label>
<input
value={
personalSection.fields.firstName?.value || ''
}
onChange={(e) =>
handleFieldChange('firstName', e.target.value)
}
onBlur={() => handleFieldBlur('firstName')}
/>
{personalSection.fields.firstName?.error && (
<span className='error'>
{personalSection.fields.firstName.error}
</span>
)}
</div>
<div>
<label>姓</label>
<input
value={
personalSection.fields.lastName?.value || ''
}
onChange={(e) =>
handleFieldChange('lastName', e.target.value)
}
onBlur={() => handleFieldBlur('lastName')}
/>
{personalSection.fields.lastName?.error && (
<span className='error'>
{personalSection.fields.lastName.error}
</span>
)}
</div>
</div>
);
};
まとめ
Zustand でネストした状態を効率的に管理するためには、適切な設計とツールの活用が欠かせません。本記事でご紹介した手法をまとめると、以下のようになります。
設計の原則として、浅い更新と深い更新の使い分けを理解し、パフォーマンスと可読性のバランスを取ることが重要です。単純な状態変更には浅い更新を、複雑な階層構造には immer を活用した深い更新を選択しましょう。
実装のテクニックでは、immer ライブラリとの連携により、ミュータブルな書き方でイミュータブルな更新を実現できます。また、スライスパターンによる分割管理で、大規模なアプリケーションでもコードの整理と保守性を保てるのです。
型安全性の確保については、TypeScript との組み合わせで、コンパイル時にエラーを検出し、開発効率を向上させることができます。
実際のプロジェクトでは、アプリケーションの規模と複雑さに応じて、これらの手法を組み合わせて使用することになるでしょう。小さく始めて、必要に応じて段階的に高度なパターンを導入していくアプローチをお勧めします。
ネストした状態管理は一見複雑に見えますが、適切な設計と実装パターンを身につけることで、保守性の高い状態管理システムを構築できるようになります。ぜひ、本記事の内容を参考に、実際のプロジェクトで活用してみてください。
関連リンク
- blog
「アジャイルコーチ」って何する人?チームを最強にする影の立役者の役割と、あなたがコーチになるための道筋
- blog
ペアプロって本当に効果ある?メリットだけじゃない、現場で感じたリアルな課題と乗り越え方
- blog
TDDって結局何がいいの?コードに自信が持てる、テスト駆動開発のはじめの一歩
- blog
「昨日やったこと、今日やること」の報告会じゃない!デイリースクラムをチームのエンジンにするための3つの問いかけ
- blog
燃え尽きるのは誰だ?バーンダウンチャートでプロジェクトの「ヤバさ」をチームで共有する方法
- blog
「誰が、何を、なぜ」が伝わらないユーザーストーリーは無意味。開発者が本当に欲しいストーリーの書き方