18.5 스토어와 상태 공유
Solid.js에서 단일 컴포넌트 내부의 상태는 createSignal로 충분합니다. 하지만 중첩된 객체 구조나 여러 컴포넌트가 공유하는 복잡한 상태는 스토어(Store) 가 훨씬 강력한 도구입니다. 이 장에서는 createStore의 원리부터 Context API를 통한 전역 상태 공유, 서드파티 상태 관리 라이브러리까지 체계적으로 배웁니다.
1. createStore — 중첩 반응형 상태
기본 개념
createSignal은 단일 값에 대한 반응형이지만, createStore는 중첩 객체와 배열 전체를 Proxy로 감싸 어떤 깊이의 프로퍼티를 읽어도 자동으로 반응형 추적이 이루어집니다.
import { createStore } from 'solid-js/store';
const [state, setState] = createStore({
user: {
name: '홍길동',
age: 30,
address: {
city: '서울',
district: '강남구',
},
},
todos: [],
settings: {
theme: 'dark',
language: 'ko',
},
});
// 읽기 — 중첩 프로퍼티도 자동 추적
console.log(state.user.name); // '홍길동'
console.log(state.user.address.city); // '서울'
createSignal과 달리 state는 함수가 아니라 객체 자체입니다. 프로퍼티를 직접 점(.) 표기법으로 읽습니다.
기본 업데이트
import { createStore } from 'solid-js/store';
function UserProfile() {
const [user, setUser] = createStore({
name: '홍길동',
age: 30,
email: 'hong@example.com',
});
return (
<div>
<p>이름: {user.name}</p>
<p>나이: {user.age}</p>
<p>이메일: {user.email}</p>
<button onClick={() => setUser('name', '김철수')}>
이름 변경
</button>
<button onClick={() => setUser('age', a => a + 1)}>
나이 증가
</button>
</div>
);
}
setUser('name', '김철수') — 경로 기반 업데이트입니다. 첫 번째 인자부터 마지막 두 번째 인자까지가 경로, 마지막 인자가 새 값 또는 업데이트 함수입니다.
2. 경로 기반 업데이트 — setStore 심화
중첩 경로 업데이트
import { createStore } from 'solid-js/store';
function AddressEditor() {
const [state, setState] = createStore({
user: {
name: '홍길동',
address: {
city: '서울',
district: '강남구',
zipCode: '06000',
},
},
});
const updateCity = (newCity) => {
// 경로: 'user' → 'address' → 'city'
setState('user', 'address', 'city', newCity);
};
const updateAddress = (newAddress) => {
// 중첩 객체 일부 병합 (스프레드 없이!)
setState('user', 'address', newAddress);
};
return (
<div>
<p>도시: {state.user.address.city}</p>
<p>구: {state.user.address.district}</p>
<button onClick={() => updateCity('부산')}>
부산으로 변경
</button>
<button onClick={() => updateAddress({ city: '인천', district: '연수구' })}>
주소 일부 변경
</button>
</div>
);
}
배열 업데이트
import { createStore } from 'solid-js/store';
function TodoList() {
const [store, setStore] = createStore({
todos: [
{ id: 1, text: '장보기', done: false },
{ id: 2, text: '운동하기', done: false },
{ id: 3, text: '독서', done: true },
],
});
// 인덱스로 특정 아이템 업데이트
const toggleTodo = (index) => {
setStore('todos', index, 'done', d => !d);
};
// 배열 전체 교체
const clearDone = () => {
setStore('todos', todos => todos.filter(t => !t.done));
};
// 새 아이템 추가
const addTodo = (text) => {
setStore('todos', todos => [
...todos,
{ id: Date.now(), text, done: false },
]);
};
// 필터 함수로 여러 아이템 한번에 업데이트
const completeAll = () => {
setStore('todos', t => !t.done, 'done', true);
// 첫 번째 인자가 함수이면 각 아이템에 필터 적용
};
return (
<div>
<ul>
<For each={store.todos}>
{(todo, i) => (
<li
style={{ 'text-decoration': todo.done ? 'line-through' : 'none' }}
onClick={() => toggleTodo(i())}
>
{todo.text}
</li>
)}
</For>
</ul>
<button onClick={clearDone}>완료 항목 삭제</button>
<button onClick={completeAll}>모두 완료</button>
<button onClick={() => addTodo('새 할 일')}>추가</button>
</div>
);
}
범위 기반 업데이트 (배열 슬라이스)
// 인덱스 범위로 여러 아이템 업데이트
setStore('items', { from: 2, to: 5 }, 'selected', true);
// 홀수 인덱스만 업데이트
setStore('items', (_, i) => i % 2 === 1, 'highlighted', true);
3. produce — Immer 스타일 뮤테이션
produce는 Immer에서 영감을 받은 헬퍼로, 불변 업데이트를 뮤테이션 스타일로 작성할 수 있게 해줍니다. 복잡한 중첩 업데이트에서 코드가 훨씬 간결해집니다.
import { createStore, produce } from 'solid-js/store';
function CartManager() {
const [cart, setCart] = createStore({
items: [
{ id: 1, name: '노트북', price: 1200000, qty: 1 },
{ id: 2, name: '마우스', price: 35000, qty: 2 },
],
coupon: null,
shippingFree: false,
});
// produce 없이 — 장황하고 읽기 어려움
const incrementQtyOld = (id) => {
setCart('items', item => item.id === id, 'qty', q => q + 1);
};
// produce 사용 — 직관적인 뮤테이션 스타일
const incrementQty = (id) => {
setCart(produce(draft => {
const item = draft.items.find(i => i.id === id);
if (item) item.qty += 1;
}));
};
const removeItem = (id) => {
setCart(produce(draft => {
const index = draft.items.findIndex(i => i.id === id);
if (index !== -1) draft.items.splice(index, 1);
}));
};
const applyCoupon = (code) => {
setCart(produce(draft => {
draft.coupon = { code, discount: 0.1 };
if (draft.items.reduce((sum, i) => sum + i.price * i.qty, 0) >= 50000) {
draft.shippingFree = true;
}
}));
};
const totalPrice = () =>
cart.items.reduce((sum, item) => sum + item.price * item.qty, 0);
return (
<div>
<For each={cart.items}>
{(item) => (
<div>
<span>{item.name} x {item.qty}</span>
<span>{(item.price * item.qty).toLocaleString()}원</span>
<button onClick={() => incrementQty(item.id)}>+</button>
<button onClick={() => removeItem(item.id)}>삭제</button>
</div>
)}
</For>
<p>총 합계: {totalPrice().toLocaleString()}원</p>
<button onClick={() => applyCoupon('SAVE10')}>쿠폰 적용</button>
</div>
);
}
produce의 반환값 활용
// produce 내부에서 값을 반환하면 해당 값으로 전체 스토어를 교체
setStore(produce(draft => {
// 반환하지 않으면 draft 뮤테이션 적용
draft.status = 'active';
}));
// 완전히 새 값으로 교체하려면 반환
setStore(produce(() => ({
items: [],
status: 'reset',
})));
4. reconcile — 외부 데이터와 동기화
reconcile은 서버에서 가져온 새 데이터를 스토어에 병합할 때 기존 참조를 최대한 유지해 불필요한 리렌더링을 방지합니다.
import { createStore, reconcile } from 'solid-js/store';
function UserList() {
const [store, setStore] = createStore({ users: [] });
const fetchUsers = async () => {
const response = await fetch('/api/users');
const newUsers = await response.json();
// reconcile 없이 — 배열 전체 교체, 모든 행 리렌더링
// setStore('users', newUsers);
// reconcile 사용 — id 기준으로 diff, 변경된 항목만 리렌더링
setStore('users', reconcile(newUsers, { key: 'id', merge: true }));
};
onMount(fetchUsers);
return (
<For each={store.users}>
{(user) => <UserRow user={user} />}
</For>
);
}
reconcile 옵션:
key: 동일성 비교 기준 필드 (기본값:'id')merge:true이면 기존 객체에 새 프로퍼티를 병합,false이면 완전 교체
5. Context API — 전역 상태 공유
createContext와 useContext
Context는 컴포넌트 트리를 통해 props를 직접 전달하지 않고 상태를 공유하는 메커니즘입니다.
import { createContext, useContext, createSignal } from 'solid-js';
// 1. Context 생성 — 기본값 지정
const CounterContext = createContext(0);
// 2. Provider 컴포넌트
function CounterProvider(props) {
const [count, setCount] = createSignal(0);
return (
<CounterContext.Provider value={[count, setCount]}>
{props.children}
</CounterContext.Provider>
);
}
// 3. 소비 컴포넌트
function CounterDisplay() {
const [count] = useContext(CounterContext);
return <p>카운트: {count()}</p>;
}
function CounterButton() {
const [, setCount] = useContext(CounterContext);
return <button onClick={() => setCount(c => c + 1)}>증가</button>;
}
// 4. 앱에서 사용
function App() {
return (
<CounterProvider>
<CounterDisplay />
<CounterButton />
</CounterProvider>
);
}
타입 안전한 Context 패턴
import { createContext, useContext, createSignal } from 'solid-js';
// Context 타입 정의
const ThemeContext = createContext({
theme: () => 'light',
toggleTheme: () => {},
});
// Provider — 실제 구현
function ThemeProvider(props) {
const [theme, setTheme] = createSignal('light');
const toggleTheme = () =>
setTheme(t => (t === 'light' ? 'dark' : 'light'));
const value = { theme, toggleTheme };
return (
<ThemeContext.Provider value={value}>
{props.children}
</ThemeContext.Provider>
);
}
// 커스텀 훅으로 편의성 제공
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme은 ThemeProvider 내부에서 사용해야 합니다');
return ctx;
}
// 소비
function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
현재 테마: {theme()}
</button>
);
}
createStore + Context 조합
대규모 앱에서 가장 흔하게 사용하는 패턴입니다.
import { createContext, useContext } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
// --- 타입 정의 ---
const defaultCart = {
items: [],
isOpen: false,
};
const CartContext = createContext();
// --- Provider ---
export function CartProvider(props) {
const [cart, setCart] = createStore(defaultCart);
const actions = {
addItem(product) {
setCart(produce(draft => {
const existing = draft.items.find(i => i.id === product.id);
if (existing) {
existing.qty += 1;
} else {
draft.items.push({ ...product, qty: 1 });
}
}));
},
removeItem(id) {
setCart('items', items => items.filter(i => i.id !== id));
},
updateQty(id, qty) {
if (qty <= 0) {
actions.removeItem(id);
return;
}
setCart('items', item => item.id === id, 'qty', qty);
},
clearCart() {
setCart('items', []);
},
toggleCart() {
setCart('isOpen', v => !v);
},
};
const derived = {
totalItems: () => cart.items.reduce((sum, i) => sum + i.qty, 0),
totalPrice: () => cart.items.reduce((sum, i) => sum + i.price * i.qty, 0),
};
return (
<CartContext.Provider value={{ cart, actions, derived }}>
{props.children}
</CartContext.Provider>
);
}
// --- 커스텀 훅 ---
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart은 CartProvider 내부에서 사용해야 합니다');
return ctx;
}
6. 전역 상태 관리 패턴
모듈 스코프 시그널 — 가장 단순한 전역 상태
// store/auth.js
import { createSignal } from 'solid-js';
// 모듈 레벨에서 생성 — 앱 전체에서 공유
const [user, setUser] = createSignal(null);
const [isLoading, setIsLoading] = createSignal(false);
export const authStore = {
user,
isLoading,
async login(credentials) {
setIsLoading(true);
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
const userData = await res.json();
setUser(userData);
} finally {
setIsLoading(false);
}
},
logout() {
setUser(null);
},
isAuthenticated: () => user() !== null,
};
// 아무 컴포넌트에서나 import해서 사용
import { authStore } from './store/auth';
function Navbar() {
return (
<nav>
<Show when={authStore.user()} fallback={<LoginButton />}>
<span>안녕하세요, {authStore.user().name}님</span>
<button onClick={authStore.logout}>로그아웃</button>
</Show>
</nav>
);
}
Context vs 전역 시그널 — 언제 무엇을?
| 기준 | 전역 시그널 (모듈 스코프) | Context API |
|---|---|---|
| 설정 복잡도 | 매우 간단 | Provider 설정 필요 |
| 테스트 독립성 | 낮음 (상태 공유) | 높음 (Provider 교체 가능) |
| SSR 안전성 | 위험 (요청 간 상태 누출) | 안전 |
| 적합한 사용처 | CSR 전용 앱, 빠른 프로토타입 | SSR 앱, 테스트가 중요한 앱 |
| 다중 인스턴스 | 불가 | 가능 (중첩 Provider) |
SSR 환경에서는 반드시 Context를 사용하세요. 모듈 스코프 시그널은 서버에서 요청 간 상태가 공유되어 보안 취약점이 됩니다.
도메인별 Context 분리 전략
// 각 도메인별로 Context를 분리해 관심사 분리
function App() {
return (
<AuthProvider>
<ThemeProvider>
<CartProvider>
<NotificationProvider>
<Router>
<AppRoutes />
</Router>
</NotificationProvider>
</CartProvider>
</ThemeProvider>
</AuthProvider>
);
}
Context가 많아지면 단일 AppProvider로 합성하는 패턴도 효과적입니다.
// providers/index.jsx
function AppProvider(props) {
return (
<AuthProvider>
<ThemeProvider>
<CartProvider>
{props.children}
</CartProvider>
</ThemeProvider>
</AuthProvider>
);
}
7. 서드파티 상태 관리
@tanstack/solid-query
서버 상태(서버에서 가져오는 데이터)는 @tanstack/solid-query가 가장 강력합니다.
npm install @tanstack/solid-query
import {
QueryClient,
QueryClientProvider,
createQuery,
createMutation,
useQueryClient,
} from '@tanstack/solid-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분간 fresh
gcTime: 1000 * 60 * 10, // 10분 후 GC
},
},
});
// Provider 설정
function App() {
return (
<QueryClientProvider client={queryClient}>
<ProductList />
</QueryClientProvider>
);
}
// 데이터 조회
function ProductList() {
const productsQuery = createQuery(() => ({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
}));
return (
<Switch>
<Match when={productsQuery.isPending}>
<p>로딩 중...</p>
</Match>
<Match when={productsQuery.isError}>
<p>오류: {productsQuery.error.message}</p>
</Match>
<Match when={productsQuery.isSuccess}>
<For each={productsQuery.data}>
{(product) => <ProductCard product={product} />}
</For>
</Match>
</Switch>
);
}
// 데이터 변경
function AddProduct() {
const qc = useQueryClient();
const addMutation = createMutation(() => ({
mutationFn: (newProduct) =>
fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct),
}).then(r => r.json()),
onSuccess: () => {
// 제품 목록 쿼리 무효화 → 자동 재조회
qc.invalidateQueries({ queryKey: ['products'] });
},
}));
return (
<button
onClick={() => addMutation.mutate({ name: '새 제품', price: 10000 })}
disabled={addMutation.isPending}
>
{addMutation.isPending ? '추가 중...' : '제품 추가'}
</button>
);
}
solid-zustand
Zustand 스타일의 간결한 상태 관리를 선호한다면:
npm install solid-zustand zustand
import { create } from 'solid-zustand';
const useBearStore = create(set => ({
bears: 0,
fish: [],
addBear: () => set(state => ({ bears: state.bears + 1 })),
addFish: (fish) => set(state => ({ fish: [...state.fish, fish] })),
reset: () => set({ bears: 0, fish: [] }),
}));
function BearCounter() {
const bears = useBearStore(state => state.bears);
const addBear = useBearStore(state => state.addBear);
return (
<div>
<h1>{bears} 마리의 곰</h1>
<button onClick={addBear}>곰 추가</button>
</div>
);
}
8. 실전 예제
예제 1: 완전한 쇼핑 카트 구현
import { createContext, useContext, createSignal, Show, For } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
// ============ 타입 & 초기 상태 ============
const initialCartState = {
items: [],
isOpen: false,
promoCode: '',
discount: 0,
};
// ============ Context 생성 ============
const CartContext = createContext();
// ============ Provider ============
function CartProvider(props) {
const [cart, setCart] = createStore(initialCartState);
const totalItems = () => cart.items.reduce((sum, i) => sum + i.qty, 0);
const subtotal = () => cart.items.reduce((sum, i) => sum + i.price * i.qty, 0);
const discountAmount = () => subtotal() * cart.discount;
const total = () => subtotal() - discountAmount();
const addItem = (product) => {
setCart(produce(draft => {
const found = draft.items.find(i => i.id === product.id);
if (found) {
found.qty += 1;
} else {
draft.items.push({ ...product, qty: 1 });
}
}));
};
const removeItem = (id) =>
setCart('items', items => items.filter(i => i.id !== id));
const setQty = (id, qty) => {
if (qty < 1) { removeItem(id); return; }
setCart('items', item => item.id === id, 'qty', qty);
};
const applyPromo = (code) => {
const promos = { SAVE10: 0.1, SAVE20: 0.2 };
const discount = promos[code.toUpperCase()] ?? 0;
setCart({ promoCode: code, discount });
};
const checkout = async () => {
const orderData = {
items: cart.items,
total: total(),
promoCode: cart.promoCode,
};
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData),
});
if (res.ok) {
setCart({ items: [], promoCode: '', discount: 0, isOpen: false });
alert('주문 완료!');
}
};
return (
<CartContext.Provider value={{
cart, totalItems, subtotal, discountAmount, total,
addItem, removeItem, setQty, applyPromo, checkout,
openCart: () => setCart('isOpen', true),
closeCart: () => setCart('isOpen', false),
}}>
{props.children}
</CartContext.Provider>
);
}
function useCart() {
return useContext(CartContext);
}
// ============ 카트 UI ============
function CartDrawer() {
const { cart, total, discountAmount, removeItem, setQty, applyPromo, checkout, closeCart } = useCart();
const [promoInput, setPromoInput] = createSignal('');
return (
<Show when={cart.isOpen}>
<div class="cart-overlay" onClick={closeCart} />
<div class="cart-drawer">
<h2>장바구니</h2>
<For each={cart.items} fallback={<p>장바구니가 비어 있습니다</p>}>
{(item) => (
<div class="cart-item">
<img src={item.image} alt={item.name} />
<div>
<p>{item.name}</p>
<p>{item.price.toLocaleString()}원</p>
</div>
<div class="qty-control">
<button onClick={() => setQty(item.id, item.qty - 1)}>-</button>
<span>{item.qty}</span>
<button onClick={() => setQty(item.id, item.qty + 1)}>+</button>
</div>
<button onClick={() => removeItem(item.id)}>삭제</button>
</div>
)}
</For>
<div class="promo-section">
<input
value={promoInput()}
onInput={e => setPromoInput(e.target.value)}
placeholder="프로모션 코드"
/>
<button onClick={() => applyPromo(promoInput())}>적용</button>
<Show when={cart.discount > 0}>
<p>할인: -{discountAmount().toLocaleString()}원</p>
</Show>
</div>
<p class="total">합계: {total().toLocaleString()}원</p>
<button class="checkout-btn" onClick={checkout}>결제하기</button>
</div>
</Show>
);
}
// ============ 상품 카드 ============
function ProductCard(props) {
const { addItem } = useCart();
return (
<div class="product-card">
<img src={props.product.image} alt={props.product.name} />
<h3>{props.product.name}</h3>
<p>{props.product.price.toLocaleString()}원</p>
<button onClick={() => addItem(props.product)}>장바구니 추가</button>
</div>
);
}
// ============ 앱 ============
function ShopApp() {
return (
<CartProvider>
<CartNavbar />
<ProductGrid />
<CartDrawer />
</CartProvider>
);
}
예제 2: 테마 토글 — 다크/라이트 모드
import { createContext, useContext, createSignal, onMount } from 'solid-js';
const ThemeContext = createContext();
function ThemeProvider(props) {
const [theme, setTheme] = createSignal(
localStorage.getItem('theme') || 'light'
);
// DOM에 테마 클래스 적용
const applyTheme = (t) => {
document.documentElement.classList.toggle('dark', t === 'dark');
localStorage.setItem('theme', t);
};
onMount(() => applyTheme(theme()));
const toggleTheme = () => {
const next = theme() === 'light' ? 'dark' : 'light';
setTheme(next);
applyTheme(next);
};
// 시스템 다크 모드 감지
onMount(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
if (!localStorage.getItem('theme')) {
const sys = mq.matches ? 'dark' : 'light';
setTheme(sys);
applyTheme(sys);
}
const handler = (e) => {
if (!localStorage.getItem('theme')) {
const t = e.matches ? 'dark' : 'light';
setTheme(t);
applyTheme(t);
}
};
mq.addEventListener('change', handler);
onCleanup(() => mq.removeEventListener('change', handler));
});
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{props.children}
</ThemeContext.Provider>
);
}
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('ThemeProvider 밖에서 useTheme 호출');
return ctx;
}
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
aria-label="테마 전환"
class="theme-toggle"
>
{theme() === 'light' ? '🌙 다크 모드' : '☀️ 라이트 모드'}
</button>
);
}
예제 3: 사용자 인증 상태 관리
import { createContext, useContext, createSignal, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
const AuthContext = createContext();
function AuthProvider(props) {
const [auth, setAuth] = createStore({
user: null,
token: null,
loading: true,
error: null,
});
// 앱 시작 시 토큰 복원
onMount(async () => {
const token = localStorage.getItem('token');
if (!token) {
setAuth('loading', false);
return;
}
try {
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const user = await res.json();
setAuth({ user, token, loading: false });
} else {
localStorage.removeItem('token');
setAuth('loading', false);
}
} catch {
setAuth({ loading: false, error: '인증 확인 실패' });
}
});
const login = async (email, password) => {
setAuth({ error: null });
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const { message } = await res.json();
setAuth({ error: message });
return false;
}
const { user, token } = await res.json();
localStorage.setItem('token', token);
setAuth({ user, token, error: null });
return true;
} catch (e) {
setAuth({ error: '로그인 중 오류가 발생했습니다' });
return false;
}
};
const logout = () => {
localStorage.removeItem('token');
setAuth({ user: null, token: null });
};
const updateProfile = async (data) => {
const res = await fetch('/api/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
setAuth('user', updated);
}
};
return (
<AuthContext.Provider value={{
auth,
isAuthenticated: () => auth.user !== null,
isAdmin: () => auth.user?.role === 'admin',
login,
logout,
updateProfile,
}}>
{props.children}
</AuthContext.Provider>
);
}
function useAuth() {
return useContext(AuthContext);
}
// 보호된 라우트 컴포넌트
function ProtectedRoute(props) {
const { auth, isAuthenticated } = useAuth();
return (
<Show when={!auth.loading} fallback={<LoadingSpinner />}>
<Show when={isAuthenticated()} fallback={<Navigate href="/login" />}>
{props.children}
</Show>
</Show>
);
}
// 로그인 폼
function LoginForm() {
const { login, auth } = useAuth();
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const handleSubmit = async (e) => {
e.preventDefault();
const success = await login(email(), password());
if (success) window.location.href = '/dashboard';
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email()}
onInput={e => setEmail(e.target.value)}
placeholder="이메일"
required
/>
<input
type="password"
value={password()}
onInput={e => setPassword(e.target.value)}
placeholder="비밀번호"
required
/>
<Show when={auth.error}>
<p class="error">{auth.error}</p>
</Show>
<button type="submit">로그인</button>
</form>
);
}
9. 고수 팁
팁 1: 스토어 셀렉터로 세밀한 구독
// 전체 스토어를 구독하지 말고, 필요한 부분만 파생 시그널로 추출
const [state, setState] = createStore({ user: { name: '홍길동', age: 30 } });
// 이름만 추적하는 메모
const userName = createMemo(() => state.user.name);
// 컴포넌트에서 사용 — name이 바뀔 때만 리렌더링
function NameDisplay() {
return <h1>{userName()}</h1>;
}
팁 2: Context 기본값을 활용한 테스트
// 기본값에 mock 데이터를 넣으면 Provider 없이도 테스트 가능
const CartContext = createContext({
cart: { items: [] },
totalItems: () => 0,
addItem: () => {},
});
팁 3: 불변 스냅샷이 필요할 때
import { unwrap } from 'solid-js/store';
// Proxy를 벗겨서 순수 JavaScript 객체로 변환
const plainObject = unwrap(state);
console.log(JSON.stringify(plainObject)); // JSON 직렬화 가능
팁 4: createStore 초기화를 lazy하게
// 무거운 초기 데이터는 함수로 지연 계산
const [state, setState] = createStore(() => ({
items: computeExpensiveInitialData(),
}));
팁 5: 미세 업데이트 — 경로 지정의 힘
// 안 좋음 — 전체 user 객체를 새로 설정
setState('user', { ...state.user, name: '김철수' });
// 좋음 — name만 정확히 업데이트, 다른 프로퍼티 추적 컴포넌트는 리렌더링 없음
setState('user', 'name', '김철수');
팁 6: solid-js/store 모든 import 알아두기
import {
createStore, // 반응형 스토어 생성
produce, // Immer 스타일 뮤테이션
reconcile, // 외부 데이터 병합
unwrap, // Proxy 제거, 순수 객체 추출
createMutable, // 직접 뮤테이션 가능한 스토어 (고급)
} from 'solid-js/store';
createMutable은 Vue의 reactive와 유사합니다. 외부 라이브러리 통합 등 특수한 경우에 사용합니다.
import { createMutable } from 'solid-js/store';
const state = createMutable({ count: 0, name: 'test' });
// 직접 뮤테이션 가능 (setter 함수 불필요)
state.count++;
state.name = '변경됨';
정리
| 도구 | 용도 |
|---|---|
createStore | 중첩 객체/배열 반응형 상태 |
setStore (경로 기반) | 중첩 프로퍼티 세밀한 업데이트 |
produce | Immer 스타일 뮤테이션으로 복잡한 업데이트 단순화 |
reconcile | 서버 데이터와 스토어 동기화, 최소 리렌더링 |
unwrap | 스토어 Proxy 제거, 직렬화 필요 시 |
createContext + useContext | 컴포넌트 트리 전체에 상태 공유 |
| 모듈 스코프 시그널 | CSR 전용 간단한 전역 상태 |
@tanstack/solid-query | 서버 상태, 캐싱, 동기화 |
다음 장에서는...
18.6 SolidStart 기초에서는 Solid.js의 풀스택 메타프레임워크인 SolidStart를 배웁니다. 파일 기반 라우팅, 서버 함수, SSR/SSG 설정, 그리고 배포 어댑터를 통해 완전한 풀스택 애플리케이션을 구축하는 방법을 익힙니다.