本节摘要:面对嵌套对象与列表,React 的答案是"不可变更新"——整棵子树克隆出新引用再触发重渲染;Solid 的答案是
createStore——把对象做成路径级响应式,改哪个字段就通知哪个字段的订阅者。本节并排实现同一个"用户设置面板",展示路径赋值、produce草稿修改、reconcile大对象对齐三种写法,并给出 Signal 与 Store 的选型分界。
不是所有状态都适合压扁成一个个独立的 Signal。用户设置面板长这样:{ profile: { name, avatar }, notify: { email, sms, push }, tags: [...] }——三层嵌套加一个数组。拆成七八个 Signal 当然可行,但读写会散落各处;整体存进一个 Signal 又回到了老问题:改一个开关,整个对象换新引用,所有读它的地方全部重新计算。Solid 为这种形态准备了专用原语:createStore。
React 版本,感受一下不可变更新的样板量:
function Settings() { const [state, setState] = useState(initialState); const rename = name => setState(s => ({ ...s, // 克隆第一层 profile: { ...s.profile, name } // 再克隆第二层 })); const toggleNotify = key => setState(s => ({ ...s, notify: { ...s.notify, [key]: !s.notify[key] } // 每个字段一套写法 })); const addTag = tag => setState(s => ({ ...s, tags: [...s.tags, tag] })); }
Solid 版本:
import { createStore, produce } from "solid-js/store"; function Settings() { const [state, setState] = createStore(initialState); const rename = name => setState("profile", "name", name); // 路径赋值 const toggleNotify = key => setState("notify", key, v => !v); const addTag = tag => setState("tags", t => [...t, tag]); // 数组仍推荐函数式 // 嵌套层级深、改动点多的场景,用草稿式修改更顺手 const bulkEdit = () => setState(produce(s => { s.profile.name = "新名字"; s.notify.email = false; s.tags.push("临时标签"); })); }
setState("profile", "name", name) 这行是两种哲学的分水岭:它没有克隆任何东西,只是在 store 的代理结构上找到那个字段,换掉值,然后通知恰好订阅了这条路径的绑定。React 版本做同样的事要克隆两层对象——不是 React 喜欢浪费,而是它的更新通道只有"引用变化触发重渲染"这一条,不换引用,通道就静默。不可变更新是重渲染模型的燃料,细粒度响应式不需要烧这种燃料。

produce 把 Immer 式的写法内建进来:草稿对象上随便改,收尾时 Solid 自动算出"实际动了哪些路径",只通知这些路径。适合改动点分散、逻辑连续的场景,比如表单批量回填。
reconcile 处理另一个极端:你从服务端拿到了一份全新的大对象(整个设置面板、一篇文档),想整体替换又不想让"值没变的路径"白白触发更新。setState(reconcile(newData)) 会做深度对齐,逐路径比较,只把真正变化的路径广播出去。它相当于把 diff 从"虚拟 DOM 对 DOM"搬到了"新数据对 store",粒度依然是路径级。
⚠️ store 里的对象是代理,展开运算符会把响应式丢掉。
{...state.profile}得到的是静态快照,后续更新不会流进来。需要"挑几个字段还保持响应"时,用第 3 章会讲的mergeProps或在绑定层按路径读取。
给一个可操作的判断顺序。值是原始类型(数字、字符串、布尔)——用 Signal。值是对象但整体替换、从不深改(比如一个选中的 tab 对象)——也用 Signal。值会被深层修改,或者列表条目多、条目字段多——用 Store。状态需要在组件树深处共享——两种都可以配合 Context(第 5 章)。
还有一个常被忽略的维度:迁移成本。React 项目里已按不可变规范写好的 reducer 逻辑,搬到 Signal 上要重写更新路径;搬到 Store 加 produce 上则几乎是平移——草稿式修改与 reducer 内的"伪可变"写法形似度很高。团队从 Redux 系迁移时,Store 往往是最先落地的那块拼图。
把 2.2 结尾埋的线头接上:Redux 系项目的迁移最顺。推演一个真实片段——购物车的 reducer:
// 原 reducer:集中描述状态迁移 function cartReducer(state, action) { switch (action.type) { case "add": return { ...state, items: [...state.items, action.item] }; case "remove": return { ...state, items: state.items.filter(i => i.id !== action.id) }; case "clear": return { ...state, items: [] }; } } // Solid 版:意图函数语义不变,样板消失 const [cart, setCart] = createStore({ items: [] }); const actions = { add: item => setCart("items", list => [...list, item]), remove: id => setCart("items", list => list.filter(i => i.id !== id)), clear: () => setCart("items", []), };
迁移要点有两条。意图函数仍然集中、仍然可以单独测试——可测性是 reducer 范式的核心资产,迁移后不丢。时间旅行调试这一配套设施则没有对位物:状态树不再是纯函数链的产物,回放工具失效。放弃它之前先问一句真实使用率,多数团队的历史回放使用频率低到不足以构成保留理由。
能存但不响应。store 的代理只拦截数据访问,函数成员原样透传;第三方实例(地图对象、播放器)放进 store 不会获得响应性,还可能因代理包装出诡异行为。规范做法是:store 只放数据,实例用组件内变量加 onCleanup 管理,需要联动时用信号存实例的"状态字段"而非实例本身。
复合状态讲完了。下一节补上派生计算这块拼图——createMemo 对 useMemo,"提示"与"保证"的一字之差。