Skip to content

useStoreValue 패턴

선택적 업데이트, 조건부 구독, 비교 전략을 통한 스토어 변경 구독을 위한 핵심 useStoreValue 패턴.

사전 요구사항

이 가이드는 기본 스토어 설정 가이드의 스토어 컨텍스트를 사용합니다.

필수 스토어 설정

typescript
import { createStoreContext, useStoreValue } from '@context-action/react';

// 사용자 도메인 스토어
const {
  Provider: UserStoreProvider,
  useStore: useUserStore,
  useStoreManager: useUserStoreManager
} = createStoreContext('User', {
  profile: {
    initialValue: { id: '', name: '', email: '', role: 'guest' as const },
    strategy: 'shallow' as const
  },
  preferences: {
    initialValue: { theme: 'light' as const, language: 'en', notifications: true },
    strategy: 'shallow' as const
  },
  session: {
    initialValue: { isAuthenticated: false, permissions: [], lastActivity: 0 },
    strategy: 'shallow' as const
  }
});

// 제품 도메인 스토어
const {
  Provider: ProductStoreProvider,
  useStore: useProductStore,
  useStoreManager: useProductStoreManager
} = createStoreContext('Product', {
  catalog: [] as Product[],
  cart: {
    initialValue: { items: [], total: 0, discounts: [] },
    strategy: 'shallow' as const
  },
  filters: {
    initialValue: { category: '', priceRange: null, searchTerm: '' },
    strategy: 'shallow' as const
  }
});

가져오기

typescript
import { useStoreValue } from '@context-action/react';

기본 스토어 구독

tsx
function UserProfile() {
  // 컨텍스트에서 스토어 인스턴스 가져오기
  const profileStore = useUserStore('profile');
  const preferencesStore = useUserStore('preferences');
  
  // 전체 스토어 값에 간단한 구독
  const profile = useStoreValue(profileStore);
  
  // 특정 필드만 구독
  const userName = useStoreValue(profileStore, user => user.name);
  const userTheme = useStoreValue(preferencesStore, prefs => prefs.theme);
  
  return (
    <div>
      <h1>{userName}</h1>
      <p>이메일: {profile.email}</p>
      <p>테마: {userTheme}</p>
    </div>
  );
}

선택적 구독

필드 선택

tsx
function UserBasicInfo() {
  const profileStore = useUserStore('profile');
  
  // 이름이 변경될 때만 재렌더링, 이메일/id/role 변경 무시
  const userName = useStoreValue(profileStore, user => user.name);
  
  // 여러 필드 선택
  const userBasicInfo = useStoreValue(profileStore, user => ({
    name: user.name,
    email: user.email
  }));
  
  return (
    <div>
      <h2>{userName}</h2>
      <p>{userBasicInfo.email}</p>
    </div>
  );
}

깊은 속성 접근

tsx
function ProductFilters() {
  const filtersStore = useProductStore('filters');
  const cartStore = useProductStore('cart');
  
  // 제품 스토어에서 중첩 속성 접근
  const currentCategory = useStoreValue(filtersStore, filters => filters.category);
  const priceRange = useStoreValue(filtersStore, filters => filters.priceRange);
  const cartTotal = useStoreValue(cartStore, cart => cart.total);
  const itemCount = useStoreValue(cartStore, cart => cart.items.length);
  
  return (
    <div>
      <p>카테고리: {currentCategory}</p>
      <p>가격 범위: {priceRange ? `$${priceRange.min} - $${priceRange.max}` : '전체'}</p>
      <p>카트: {itemCount}개 항목, 합계: ${cartTotal}</p>
    </div>
  );
}

조건부 구독

동적 구독 제어

tsx
function ConditionalSubscriptions() {
  const sessionStore = useUserStore('session');
  const preferencesStore = useUserStore('preferences');
  
  const [subscribeToUpdates, setSubscribeToUpdates] = useState(true);
  
  // 활성화된 경우에만 구독
  const lastActivity = useStoreValue(
    subscribeToUpdates ? sessionStore : null,
    session => session?.lastActivity
  );
  
  // 사용자 기본 설정에 기반한 구독 토글  
  const notificationsEnabled = useStoreValue(preferencesStore, prefs => prefs.notifications);
  const sessionData = useStoreValue(
    notificationsEnabled ? sessionStore : null,
    session => session?.permissions
  );
  
  return (
    <div>
      <label>
        <input 
          type="checkbox" 
          checked={subscribeToUpdates}
          onChange={e => setSubscribeToUpdates(e.target.checked)}
        />
        업데이트 구독
      </label>
      {lastActivity && <p>마지막 활동: {new Date(lastActivity).toLocaleString()}</p>}
      {sessionData && <p>권한: {sessionData.length}</p>}
    </div>
  );
}

권한 기반 구독

tsx
function useProtectedStoreValue<T>(store: any, selector: any, requiredRole: string) {
  const profileStore = useUserStore('profile');
  const userRole = useStoreValue(profileStore, user => user.role);
  const hasPermission = userRole === requiredRole || userRole === 'admin';
  
  return useStoreValue(
    hasPermission ? store : null,
    selector
  );
}

function AdminPanel() {
  const cartStore = useProductStore('cart');
  
  // 관리자와 관리자 권한을 가진 사용자만 민감한 카트 데이터를 볼 수 있음
  const cartDiscounts = useProtectedStoreValue(
    cartStore,
    cart => cart.discounts,
    'admin'
  );
  
  return (
    <div>
      {cartDiscounts ? (
        <div>
          <h3>관리자: 카트 할인</h3>
          <pre>{JSON.stringify(cartDiscounts, null, 2)}</pre>
        </div>
      ) : (
        <p>접근 거부</p>
      )}
    </div>
  );
}

비교 전략

참조 비교 (기본)

tsx
function ReferenceComparison() {
  const profileStore = useUserStore('profile');
  
  // 빠른 참조 비교 - 참조가 변경된 경우에만 업데이트
  const profile = useStoreValue(profileStore, undefined, {
    comparison: 'reference'
  });
  
  return <div>사용자: {profile.name}</div>;
}

얕은 비교

tsx
function ShallowComparison() {
  const preferencesStore = useUserStore('preferences');
  
  // 최상위 속성이 변경된 경우에만 재렌더링
  const preferences = useStoreValue(preferencesStore, undefined, { 
    comparison: 'shallow' 
  });
  
  return (
    <div>
      <p>테마: {preferences.theme}</p>
      <p>언어: {preferences.language}</p>
      <p>알림: {preferences.notifications ? '켜짐' : '꺼짐'}</p>
    </div>
  );
}

깊은 비교

tsx
function DeepComparison() {
  const cartStore = useProductStore('cart');
  
  // 가장 철저하지만 비용이 많이 듦 - 드물게 사용
  const cart = useStoreValue(cartStore, undefined, {
    comparison: 'deep'
  });
  
  return (
    <div>
      <p>항목: {cart.items.length}</p>
      <p>합계: ${cart.total}</p>
    </div>
  );
}

커스텀 비교

tsx
function CustomComparison() {
  const profileStore = useUserStore('profile');
  
  const userData = useStoreValue(profileStore, user => user, {
    customComparator: (prev, next) => {
      // 이름이나 이메일이 변경된 경우에만 업데이트, 다른 필드 무시
      return prev.name === next.name && prev.email === next.email;
    }
  });
  
  return (
    <div>
      <h2>{userData.name}</h2>
      <p>{userData.email}</p>
    </div>
  );
}

변환 패턴

데이터 포맷팅

tsx
function FormattedUserDisplay() {
  const profileStore = useUserStore('profile');
  const sessionStore = useUserStore('session');
  
  const formattedUser = useStoreValue(profileStore, user => ({
    displayName: user.name || '알 수 없는 사용자',
    initials: user.name ? user.name.split(' ').map(n => n[0]).join('') : '??',
    roleDisplay: user.role.charAt(0).toUpperCase() + user.role.slice(1),
    status: user.id ? '활성' : '비활성'
  }));
  
  const lastActivity = useStoreValue(sessionStore, session => 
    session.lastActivity ? new Date(session.lastActivity).toLocaleDateString() : '없음'
  );
  
  return (
    <div>
      <h2>{formattedUser.displayName} ({formattedUser.initials})</h2>
      <p>역할: {formattedUser.roleDisplay}</p>
      <p>상태: {formattedUser.status}</p>
      <p>마지막 활동: {lastActivity}</p>
    </div>
  );
}

계산된 속성

tsx
function UserStats() {
  const profileStore = useUserStore('profile');
  const sessionStore = useUserStore('session');
  
  const userStats = useStoreValue(profileStore, user => {
    const hasPermissions = user.role === 'admin' || user.role === 'user';
    
    return {
      ...user,
      hasFullAccess: hasPermissions,
      isGuestUser: user.role === 'guest',
      canModifyData: user.role === 'admin'
    };
  });
  
  const sessionStats = useStoreValue(sessionStore, session => ({
    isAuthenticated: session.isAuthenticated,
    permissionCount: session.permissions.length,
    hasRecentActivity: Date.now() - session.lastActivity < 30 * 60 * 1000 // 30분
  }));
  
  return (
    <div>
      <h3>사용자 통계</h3>
      <p>전체 접근: {userStats.hasFullAccess ? '예' : '아니오'}</p>
      <p>수정 가능: {userStats.canModifyData ? '예' : '아니오'}</p>
      <p>권한: {sessionStats.permissionCount}</p>
      <p>최근 활동: {sessionStats.hasRecentActivity ? '예' : '아니오'}</p>
    </div>
  );
}

배열 필터링 및 매핑

tsx
function ProductCatalogSummary() {
  const catalogStore = useProductStore('catalog');
  const cartStore = useProductStore('cart');
  
  const availableProducts = useStoreValue(catalogStore, products => 
    products.filter(product => product.inStock)
  );
  
  const productNames = useStoreValue(catalogStore, products =>
    products.map(product => product.name)
  );
  
  const cartItemsByCategory = useStoreValue(cartStore, cart =>
    cart.items.reduce((acc, item) => {
      const category = item.category || 'uncategorized';
      if (!acc[category]) acc[category] = [];
      acc[category].push(item);
      return acc;
    }, {} as Record<string, any[]>)
  );
  
  return (
    <div>
      <h3>카탈로그 요약</h3>
      <p>사용 가능한 제품: {availableProducts.length}</p>
      <p>총 제품: {productNames.length}</p>
      <div>
        <h4>카테고리별 카트:</h4>
        {Object.entries(cartItemsByCategory).map(([category, items]) => (
          <p key={category}>{category}: {items.length}개 항목</p>
        ))}
      </div>
    </div>
  );
}

성능 최적화

디바운싱된 업데이트

tsx
import { useState } from 'react';

function DebouncedSearch() {
  const filtersStore = useProductStore('filters');
  
  // 빠른 검색어 변경을 디바운싱
  const debouncedSearchTerm = useStoreValue(filtersStore, filters => filters.searchTerm, {
    debounce: 300  // 마지막 변경 후 300ms 대기
  });
  
  // 입력 값을 위한 즉시 접근 (디바운싱 없음)
  const currentSearchTerm = useStoreValue(filtersStore, filters => filters.searchTerm);
  
  return (
    <div>
      <input 
        value={currentSearchTerm}
        onChange={e => filtersStore.update(prev => ({ ...prev, searchTerm: e.target.value }))}
        placeholder="제품 검색..."
      />
      <p>검색 중: "{debouncedSearchTerm}"</p>
    </div>
  );
}

메모이제이션된 셀렉터

tsx
import { useCallback } from 'react';

function OptimizedUserDisplay() {
  const profileStore = useUserStore('profile');
  const cartStore = useProductStore('cart');
  
  // 안정된 셀렉터로 불필요한 재렌더링 방지
  const userName = useStoreValue(profileStore, useCallback(
    user => user.name,
    [] // 의존성 없음
  ));
  
  // 안정된 참조를 가진 복잡한 셀렉터
  const cartSummary = useStoreValue(cartStore, useCallback(
    cart => ({
      total: cart.items.length,
      totalPrice: cart.total,
      hasItems: cart.items.length > 0,
      averageItemPrice: cart.items.length > 0 
        ? cart.total / cart.items.length
        : 0
    }),
    []
  ));
  
  return (
    <div>
      <h2>{userName}님, 환영합니다</h2>
      <div>
        <p>카트 항목: {cartSummary.total}</p>
        <p>총 가격: ${cartSummary.totalPrice}</p>
        <p>평균 가격: ${cartSummary.averageItemPrice.toFixed(2)}</p>
      </div>
    </div>
  );
}

오류 처리

안전한 속성 접근

tsx
function SafeUserDisplay() {
  const profileStore = useUserStore('profile');
  
  const safeUserData = useStoreValue(profileStore, user => {
    try {
      return {
        name: user?.name || '알 수 없는 사용자',
        email: user?.email || '이메일이 제공되지 않음',
        role: user?.role || 'guest',
        hasValidData: Boolean(user?.id)
      };
    } catch (error) {
      console.error('사용자 데이터 접근 오류:', error);
      return { 
        name: '오류', 
        email: '로딩 오류', 
        role: 'guest',
        hasValidData: false 
      };
    }
  });
  
  return (
    <div>
      <h2>{safeUserData.name}</h2>
      <p>이메일: {safeUserData.email}</p>
      <p>역할: {safeUserData.role}</p>
      {!safeUserData.hasValidData && <p>⚠️ 사용자 데이터가 완전히 로드되지 않았습니다</p>}
    </div>
  );
}

폴백 값

tsx
function UserDisplayWithFallbacks() {
  const profileStore = useUserStore('profile');
  const preferencesStore = useUserStore('preferences');
  
  const userDisplayName = useStoreValue(
    profileStore, 
    user => user?.name || user?.email || '게스트 사용자'
  );
  
  const userSettings = useStoreValue(preferencesStore, preferences => ({
    theme: preferences?.theme || 'light',
    language: preferences?.language || 'en',
    notifications: preferences?.notifications ?? true
  }));
  
  return (
    <div>
      <h2>{userDisplayName}</h2>
      <p>테마: {userSettings.theme}</p>
      <p>언어: {userSettings.language}</p>
      <p>알림: {userSettings.notifications ? '켜짐' : '꺼짐'}</p>
    </div>
  );
}

null 스토어 처리

tsx
function ConditionalStoreAccess() {
  const [shouldLoadCart, setShouldLoadCart] = useState(false);
  const cartStore = useProductStore('cart');
  
  const conditionalCartData = useStoreValue(
    shouldLoadCart ? cartStore : null,
    cart => cart ? {
      itemCount: cart.items.length,
      total: cart.total,
      hasDiscounts: cart.discounts.length > 0
    } : null
  );
  
  return (
    <div>
      <label>
        <input 
          type="checkbox"
          checked={shouldLoadCart}
          onChange={e => setShouldLoadCart(e.target.checked)}
        />
        카트 데이터 로드
      </label>
      
      {conditionalCartData ? (
        <div>
          <p>항목: {conditionalCartData.itemCount}</p>
          <p>합계: ${conditionalCartData.total}</p>
          <p>할인 있음: {conditionalCartData.hasDiscounts ? '예' : '아니오'}</p>
        </div>
      ) : (
        <p>카트 데이터가 로드되지 않았습니다</p>
      )}
    </div>
  );
}

실제 예제

사용자 프로필 표시

tsx
function UserProfile() {
  const profileStore = useUserStore('profile');
  const sessionStore = useUserStore('session');
  const preferencesStore = useUserStore('preferences');
  
  const userInfo = useStoreValue(profileStore, user => ({
    name: user.name || '익명 사용자',
    email: user.email,
    role: user.role,
    hasProfile: Boolean(user.id)
  }));
  
  const sessionInfo = useStoreValue(sessionStore, session => ({
    isOnline: Date.now() - session.lastActivity < 5 * 60 * 1000, // 5분
    isAuthenticated: session.isAuthenticated,
    lastActivity: new Date(session.lastActivity).toLocaleDateString()
  }));
  
  const theme = useStoreValue(preferencesStore, prefs => prefs.theme);
  
  return (
    <div className={`user-profile theme-${theme}`}>
      <div className="avatar-placeholder">
        {userInfo.name.charAt(0).toUpperCase()}
      </div>
      <h2>{userInfo.name}</h2>
      <p>{userInfo.email}</p>
      <div className={`status ${sessionInfo.isOnline ? 'online' : 'offline'}`}>
        {sessionInfo.isOnline ? '온라인' : '오프라인'}
      </div>
      <p>역할: {userInfo.role}</p>
      <small>마지막 활동: {sessionInfo.lastActivity}</small>
    </div>
  );
}

쇼핑 카트 배지

tsx
function CartBadge() {
  const cartStore = useProductStore('cart');
  
  const cartInfo = useStoreValue(cartStore, cart => ({
    itemCount: cart.items.reduce((sum, item) => sum + item.quantity, 0),
    hasItems: cart.items.length > 0,
    total: cart.total,
    discountCount: cart.discounts.length
  }));
  
  if (!cartInfo.hasItems) {
    return (
      <div className="cart-badge empty">
        <span className="cart-icon">🛒</span>
      </div>
    );
  }
  
  return (
    <div className="cart-badge">
      <span className="cart-icon">🛒</span>
      <span className="badge">{cartInfo.itemCount}</span>
      <span className="total">${cartInfo.total.toFixed(2)}</span>
      {cartInfo.discountCount > 0 && (
        <span className="discount">-{cartInfo.discountCount}</span>
      )}
    </div>
  );
}

제품 검색 결과

tsx
function ProductSearchResults() {
  const catalogStore = useProductStore('catalog');
  const filtersStore = useProductStore('filters');
  
  const searchState = useStoreValue(filtersStore, filters => ({
    query: filters.searchTerm,
    category: filters.category,
    priceRange: filters.priceRange
  }));
  
  const searchResults = useStoreValue(catalogStore, products => 
    products.filter(product => {
      const matchesSearch = !searchState.query || 
        product.name.toLowerCase().includes(searchState.query.toLowerCase());
      const matchesCategory = !searchState.category || 
        product.category === searchState.category;
      const matchesPrice = !searchState.priceRange || 
        (product.price >= searchState.priceRange.min && product.price <= searchState.priceRange.max);
      
      return matchesSearch && matchesCategory && matchesPrice;
    })
  );
  
  // 더 나은 성능을 위해 검색 쿼리 업데이트를 디바운싱
  const debouncedQuery = useStoreValue(filtersStore, filters => filters.searchTerm, {
    debounce: 300
  });
  
  if (!searchState.query && !searchState.category && !searchState.priceRange) {
    return <div>제품을 찾기 위해 검색 조건을 입력하세요</div>;
  }
  
  return (
    <div>
      <h3>
        검색 결과
        {debouncedQuery && ` "${debouncedQuery}"`}
        ({searchResults.length}개 발견)
      </h3>
      
      {searchState.category && <p>카테고리: {searchState.category}</p>}
      {searchState.priceRange && (
        <p>가격: ${searchState.priceRange.min} - ${searchState.priceRange.max}</p>
      )}
      
      {searchResults.length === 0 ? (
        <p>검색 조건에 일치하는 제품을 찾을 수 없습니다</p>
      ) : (
        <div className="results">
          {searchResults.map(product => (
            <div key={product.id} className="product-item">
              <h4>{product.name}</h4>
              <p>${product.price}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

모범 사례

1. 특정 셀렉터 사용

tsx
function UserDisplay() {
  const profileStore = useUserStore('profile');
  
  // ✅ 좋음: 필요한 것만 선택
  const userName = useStoreValue(profileStore, user => user.name);
  
  // ❌ 피해야 함: 이름만 필요할 때 전체 스토어에 구독
  // const user = useStoreValue(profileStore);
  // return <div>{user.name}</div>;
  
  return <div>{userName}</div>;
}

2. 복잡한 셀렉터 메모이제이션

tsx
function OptimizedCartSummary() {
  const cartStore = useProductStore('cart');
  
  // ✅ 좋음: 메모이제이션된 셀렉터
  const processedCart = useStoreValue(cartStore, useCallback(
    cart => ({
      total: cart.items.length,
      totalValue: cart.total,
      hasExpensiveItems: cart.items.some(item => item.price > 100),
      categoryBreakdown: cart.items.reduce((acc, item) => {
        acc[item.category] = (acc[item.category] || 0) + 1;
        return acc;
      }, {})
    }),
    []
  ));
  
  // ❌ 피해야 함: 매 렌더링마다 새로운 함수
  // const processedCart = useStoreValue(cartStore, cart => expensiveProcessing(cart));
  
  return (
    <div>
      <p>항목: {processedCart.total}</p>
      <p>값: ${processedCart.totalValue}</p>
      <p>비싼 항목 있음: {processedCart.hasExpensiveItems ? '예' : '아니오'}</p>
    </div>
  );
}

3. 경계 케이스 처리

tsx
function SafeUserDisplay() {
  const profileStore = useUserStore('profile');
  
  // ✅ 좋음: 안전한 속성 접근
  const userName = useStoreValue(profileStore, user => user?.name || '게스트');
  
  // ❌ 피해야 함: 속성이 존재한다고 가정
  // const userName = useStoreValue(profileStore, user => user.name);
  
  return <div>{userName}님, 환영합니다</div>;
}

4. 적절한 비교 전략 선택

tsx
function ComparisonExamples() {
  const profileStore = useUserStore('profile');
  const preferencesStore = useUserStore('preferences');
  const cartStore = useProductStore('cart');
  
  // 간단한 객체에는 - 참조 사용 (기본)
  const userId = useStoreValue(profileStore, user => user.id);
  
  // 얕은 변경이 있는 객체에는 - 얕은 사용
  const preferences = useStoreValue(preferencesStore, undefined, { comparison: 'shallow' });
  
  // 깊게 중첩된 객체에는 - 깊은 사용을 드물게
  const cartData = useStoreValue(cartStore, undefined, { comparison: 'deep' });
  
  return (
    <div>
      <p>사용자 ID: {userId}</p>
      <p>테마: {preferences.theme}</p>
      <p>카트 항목: {cartData.items.length}</p>
    </div>
  );
}

프로바이더 설정

이러한 패턴을 사용하려면 필요한 스토어 프로바이더로 컴포넌트를 래핑하세요:

tsx
import { 
  UserStoreProvider,
  ProductStoreProvider 
} from '../stores';

function App() {
  return (
    <UserStoreProvider>
      <ProductStoreProvider>
        <UserProfile />
        <CartBadge />
        <ProductSearchResults />
      </ProductStoreProvider>
    </UserStoreProvider>
  );
}

관련 패턴

Released under the Apache-2.0 License.