[{"data":1,"prerenderedAt":1378},["ShallowReactive",2],{"content-\u002Fdocs\u002Fcross-cutting-state\u002Fon-change":3},{"id":4,"title":5,"body":6,"description":1357,"extension":1358,"meta":1359,"metaRows":1360,"navigation":169,"path":1373,"seo":1374,"source":1375,"stem":1376,"__hash__":1377},"docs\u002Fdocs\u002Fcross-cutting-state\u002Fon-change.md","onChange",{"type":7,"value":8,"toc":1343},"minimark",[9,16,23,26,29,34,39,49,83,107,111,121,131,226,232,274,284,334,345,351,447,457,520,527,608,612,625,734,743,747,756,759,779,782,842,849,859,903,906,910,913,932,984,989,1002,1086,1090,1097,1181,1193,1197,1211,1242,1248,1252,1260,1264,1278,1296,1300,1339],[10,11,13],"h1",{"id":12},"onchange",[14,15,5],"code",{},[17,18,19],"blockquote",{},[20,21,22],"p",{},"A side-channel for reacting to value changes: autosave a subform, mirror one field into another, fire analytics on edit. It runs side effects and nothing else, the form's own dirty \u002F validating lifecycle stays untouched.",[24,25],"docs-meta-table",{},[20,27,28],{},"The demo wires a tiny autosave: each field saves itself ~700ms after you stop typing, and a status badge tracks every save. The badge lives in the component's own state, not the form's. Type fast to watch a stale save get superseded; tick the box to watch a failed save retry and recover; load the saved profile to watch a hydrating write land without echoing back through the save loop.",[30,31],"docs-demo",{"label":32,"slug":33},"onChange Autosave Demo","on-change",[35,36,38],"h2",{"id":37},"the-side-channel-principle","The side-channel principle",[20,40,41,43,44,48],{},[14,42,5],{}," is a pure event subscription. It fires after a value lands, hands you the new value, and gets out of the way. What it deliberately does ",[45,46,47],"strong",{},"not"," do is just as important:",[50,51,52,64],"ul",{},[53,54,55,56,59,60,63],"li",{},"It never marks the form dirty, touched, or validating. A handler running is invisible to ",[14,57,58],{},"form.meta"," (the form-level rollup) and every ",[14,61,62],{},"fields.\u003Cpath>"," flag.",[53,65,66,67,70,71,74,75,78,79,82],{},"It owns no reactive state. There is no ",[14,68,69],{},"field.saving"," or ",[14,72,73],{},"form.watching",". Autosave status lives in your own ref (the demo's status badge); validation feedback lives in ",[14,76,77],{},".refine"," and ",[14,80,81],{},"fields.\u003Cpath>.show*",".",[20,84,85,86,88,89,92,93,96,97,102,103,106],{},"That line is what keeps the surface small and the mental model honest. ",[14,87,5],{}," is the ",[45,90,91],{},"writes"," half of reacting to changes (persistence, analytics, mirroring). The ",[45,94,95],{},"reads"," half (does this value pass a server check?) belongs in an ",[98,99,101],"a",{"href":100},"\u002Fdocs\u002Fvalidation\u002Fasync-refinements","async refinement",", where the verdict flows into ",[14,104,105],{},"form.errors"," like any other validation. The two never blur together.",[35,108,110],{"id":109},"call-forms","Call forms",[20,112,113,114,117,118,82],{},"Each example assumes a ",[14,115,116],{},"form"," handle from ",[14,119,120],{},"useForm({ schema })",[20,122,123,126,127,130],{},[45,124,125],{},"The whole form."," Omit the source. The handler gets the current form value, and ",[14,128,129],{},"ctx.changed"," lists the leaf paths that moved:",[132,133,138],"pre",{"className":134,"code":135,"language":136,"meta":137,"style":137},"language-ts shiki shiki-themes github-light github-dark","const form = useForm({ schema })\n\nform.onChange((values, ctx) => {\n  analytics.track('form_edited', { fields: ctx.changed })\n})\n","ts","",[14,139,140,164,171,201,220],{"__ignoreMap":137},[141,142,145,149,153,156,160],"span",{"class":143,"line":144},"line",1,[141,146,148],{"class":147},"szBVR","const",[141,150,152],{"class":151},"sj4cs"," form",[141,154,155],{"class":147}," =",[141,157,159],{"class":158},"sScJk"," useForm",[141,161,163],{"class":162},"sVt8B","({ schema })\n",[141,165,167],{"class":143,"line":166},2,[141,168,170],{"emptyLinePlaceholder":169},true,"\n",[141,172,174,177,179,182,186,189,192,195,198],{"class":143,"line":173},3,[141,175,176],{"class":162},"form.",[141,178,5],{"class":158},[141,180,181],{"class":162},"((",[141,183,185],{"class":184},"s4XuR","values",[141,187,188],{"class":162},", ",[141,190,191],{"class":184},"ctx",[141,193,194],{"class":162},") ",[141,196,197],{"class":147},"=>",[141,199,200],{"class":162}," {\n",[141,202,204,207,210,213,217],{"class":143,"line":203},4,[141,205,206],{"class":162},"  analytics.",[141,208,209],{"class":158},"track",[141,211,212],{"class":162},"(",[141,214,216],{"class":215},"sZZnC","'form_edited'",[141,218,219],{"class":162},", { fields: ctx.changed })\n",[141,221,223],{"class":143,"line":222},5,[141,224,225],{"class":162},"})\n",[20,227,228,231],{},[45,229,230],{},"One path."," Pass a dotted path. The value is typed as that path's value, no annotation needed:",[132,233,235],{"className":134,"code":234,"language":136,"meta":137,"style":137},"form.onChange('user.email', (email, ctx) => {\n  \u002F\u002F email is inferred as string\n})\n",[14,236,237,264,270],{"__ignoreMap":137},[141,238,239,241,243,245,248,251,254,256,258,260,262],{"class":143,"line":144},[141,240,176],{"class":162},[141,242,5],{"class":158},[141,244,212],{"class":162},[141,246,247],{"class":215},"'user.email'",[141,249,250],{"class":162},", (",[141,252,253],{"class":184},"email",[141,255,188],{"class":162},[141,257,191],{"class":184},[141,259,194],{"class":162},[141,261,197],{"class":147},[141,263,200],{"class":162},[141,265,266],{"class":143,"line":166},[141,267,269],{"class":268},"sJ8bj","  \u002F\u002F email is inferred as string\n",[141,271,272],{"class":143,"line":173},[141,273,225],{"class":162},[20,275,276,279,280,283],{},[45,277,278],{},"A list of paths."," Pass an array. The handler fires once per matched path, and ",[14,281,282],{},"ctx.path"," names which one fired:",[132,285,287],{"className":134,"code":286,"language":136,"meta":137,"style":137},"form.onChange(['shipping.city', 'shipping.zip'], (value, ctx) => {\n  refreshTaxEstimate(ctx.path)\n})\n",[14,288,289,322,330],{"__ignoreMap":137},[141,290,291,293,295,298,301,303,306,309,312,314,316,318,320],{"class":143,"line":144},[141,292,176],{"class":162},[141,294,5],{"class":158},[141,296,297],{"class":162},"([",[141,299,300],{"class":215},"'shipping.city'",[141,302,188],{"class":162},[141,304,305],{"class":215},"'shipping.zip'",[141,307,308],{"class":162},"], (",[141,310,311],{"class":184},"value",[141,313,188],{"class":162},[141,315,191],{"class":184},[141,317,194],{"class":162},[141,319,197],{"class":147},[141,321,200],{"class":162},[141,323,324,327],{"class":143,"line":166},[141,325,326],{"class":158},"  refreshTaxEstimate",[141,328,329],{"class":162},"(ctx.path)\n",[141,331,332],{"class":143,"line":173},[141,333,225],{"class":162},[20,335,336,337,340,341,344],{},"An empty list (",[14,338,339],{},"form.onChange([], handler)",") lists zero paths, so it never fires. Reaching the whole form is \"omit the source,\" never \"pass ",[14,342,343],{},"[]",",\" so a dynamically built empty list can't silently become \"watch everything.\"",[20,346,347,350],{},[45,348,349],{},"A moving target."," Pass a getter, ref, or computed. Attaform re-resolves it on every write, so the aim can follow a live pointer like the active list row:",[132,352,354],{"className":134,"code":353,"language":136,"meta":137,"style":137},"const activeRow = ref(0)\n\nform.onChange(\n  () => `items.${activeRow.value}.quantity`,\n  (quantity, ctx) => {\n    \u002F\u002F re-aimed each write; ctx.path tracks the current row\n  }\n)\n",[14,355,356,376,380,389,412,430,436,442],{"__ignoreMap":137},[141,357,358,360,363,365,368,370,373],{"class":143,"line":144},[141,359,148],{"class":147},[141,361,362],{"class":151}," activeRow",[141,364,155],{"class":147},[141,366,367],{"class":158}," ref",[141,369,212],{"class":162},[141,371,372],{"class":151},"0",[141,374,375],{"class":162},")\n",[141,377,378],{"class":143,"line":166},[141,379,170],{"emptyLinePlaceholder":169},[141,381,382,384,386],{"class":143,"line":173},[141,383,176],{"class":162},[141,385,5],{"class":158},[141,387,388],{"class":162},"(\n",[141,390,391,394,396,399,402,404,406,409],{"class":143,"line":203},[141,392,393],{"class":162},"  () ",[141,395,197],{"class":147},[141,397,398],{"class":215}," `items.${",[141,400,401],{"class":162},"activeRow",[141,403,82],{"class":215},[141,405,311],{"class":162},[141,407,408],{"class":215},"}.quantity`",[141,410,411],{"class":162},",\n",[141,413,414,417,420,422,424,426,428],{"class":143,"line":222},[141,415,416],{"class":162},"  (",[141,418,419],{"class":184},"quantity",[141,421,188],{"class":162},[141,423,191],{"class":184},[141,425,194],{"class":162},[141,427,197],{"class":147},[141,429,200],{"class":162},[141,431,433],{"class":143,"line":432},6,[141,434,435],{"class":268},"    \u002F\u002F re-aimed each write; ctx.path tracks the current row\n",[141,437,439],{"class":143,"line":438},7,[141,440,441],{"class":162},"  }\n",[141,443,445],{"class":143,"line":444},8,[141,446,375],{"class":162},[20,448,449,452,453,456],{},[45,450,451],{},"At construction."," ",[14,454,455],{},"useForm({ onChange })"," registers a whole-form handler bound to the form's lifetime, handy when the handler is portable and you'd rather declare it next to the schema:",[132,458,460],{"className":134,"code":459,"language":136,"meta":137,"style":137},"const form = useForm({\n  schema,\n  onChange: (values, ctx) => {\n    draftStore.save(values)\n  },\n})\n",[14,461,462,475,480,500,511,516],{"__ignoreMap":137},[141,463,464,466,468,470,472],{"class":143,"line":144},[141,465,148],{"class":147},[141,467,152],{"class":151},[141,469,155],{"class":147},[141,471,159],{"class":158},[141,473,474],{"class":162},"({\n",[141,476,477],{"class":143,"line":166},[141,478,479],{"class":162},"  schema,\n",[141,481,482,485,488,490,492,494,496,498],{"class":143,"line":173},[141,483,484],{"class":158},"  onChange",[141,486,487],{"class":162},": (",[141,489,185],{"class":184},[141,491,188],{"class":162},[141,493,191],{"class":184},[141,495,194],{"class":162},[141,497,197],{"class":147},[141,499,200],{"class":162},[141,501,502,505,508],{"class":143,"line":203},[141,503,504],{"class":162},"    draftStore.",[141,506,507],{"class":158},"save",[141,509,510],{"class":162},"(values)\n",[141,512,513],{"class":143,"line":222},[141,514,515],{"class":162},"  },\n",[141,517,518],{"class":143,"line":432},[141,519,225],{"class":162},[20,521,522,523,526],{},"The construction option also takes a ",[14,524,525],{},"{ handler, onError }"," pair when you want error routing:",[132,528,530],{"className":134,"code":529,"language":136,"meta":137,"style":137},"useForm({\n  schema,\n  onChange: {\n    handler: (values, ctx) => draftStore.save(values),\n    onError: (error, ctx) => ctx.retry(),\n  },\n})\n",[14,531,532,539,543,548,573,600,604],{"__ignoreMap":137},[141,533,534,537],{"class":143,"line":144},[141,535,536],{"class":158},"useForm",[141,538,474],{"class":162},[141,540,541],{"class":143,"line":166},[141,542,479],{"class":162},[141,544,545],{"class":143,"line":173},[141,546,547],{"class":162},"  onChange: {\n",[141,549,550,553,555,557,559,561,563,565,568,570],{"class":143,"line":203},[141,551,552],{"class":158},"    handler",[141,554,487],{"class":162},[141,556,185],{"class":184},[141,558,188],{"class":162},[141,560,191],{"class":184},[141,562,194],{"class":162},[141,564,197],{"class":147},[141,566,567],{"class":162}," draftStore.",[141,569,507],{"class":158},[141,571,572],{"class":162},"(values),\n",[141,574,575,578,580,583,585,587,589,591,594,597],{"class":143,"line":222},[141,576,577],{"class":158},"    onError",[141,579,487],{"class":162},[141,581,582],{"class":184},"error",[141,584,188],{"class":162},[141,586,191],{"class":184},[141,588,194],{"class":162},[141,590,197],{"class":147},[141,592,593],{"class":162}," ctx.",[141,595,596],{"class":158},"retry",[141,598,599],{"class":162},"(),\n",[141,601,602],{"class":143,"line":432},[141,603,515],{"class":162},[141,605,606],{"class":143,"line":438},[141,607,225],{"class":162},[35,609,611],{"id":610},"the-handler-context","The handler context",[20,613,614,615,618,619,621,622,624],{},"Every handler receives ",[14,616,617],{},"(value, ctx)",". ",[14,620,311],{}," is the value at the source path (the whole form for a root handler); ",[14,623,191],{}," carries the rest:",[626,627,628,643],"table",{},[629,630,631],"thead",{},[632,633,634,640],"tr",{},[635,636,637,639],"th",{},[14,638,191],{}," field",[635,641,642],{},"What it holds",[644,645,646,664,674,688,702,722],"tbody",{},[632,647,648,654],{},[649,650,651],"td",{},[14,652,653],{},"path",[649,655,656,657,659,660,663],{},"The source path this fire is for, dotted (",[14,658,247],{},"); ",[14,661,662],{},"''"," for the whole form.",[632,665,666,671],{},[649,667,668],{},[14,669,670],{},"previous",[649,672,673],{},"The value at this path before the change, seeded at registration. (See the container caveat below.)",[632,675,676,681],{},[649,677,678],{},[14,679,680],{},"changed",[649,682,683,684,687],{},"The leaf paths that actually moved this dispatch, dotted. ",[14,685,686],{},"[path]"," for a leaf, the changed descendants for a container or root.",[632,689,690,695],{},[649,691,692],{},[14,693,694],{},"signal",[649,696,697,698,701],{},"An ",[14,699,700],{},"AbortSignal",", aborted when a newer write to the same source supersedes this run.",[632,703,704,709],{},[649,705,706],{},[14,707,708],{},"attempt",[649,710,711,712,714,715,718,719,82],{},"The retry counter: ",[14,713,372],{}," on the first run, bumped by ",[14,716,717],{},"onError","'s ",[14,720,721],{},"retry()",[632,723,724,728],{},[649,725,726],{},[14,727,116],{},[649,729,730,731,733],{},"The form handle, so a portable ",[14,732,455],{}," handler can reach back in (e.g. to gate on validity).",[20,735,736,739,740,742],{},[14,737,738],{},"ctx.previous"," is exact for a leaf source. For a container or the whole form, an in-place leaf edit preserves the container's reference (the same identity Attaform keeps so unrelated bindings don't re-render), so ",[14,741,670],{}," can be reference-equal to the current value, the classic deep-watch gotcha. If you need a true before \u002F after container diff, snapshot inside the handler.",[35,744,746],{"id":745},"what-fires-it-and-what-doesnt","What fires it, and what doesn't",[20,748,749,751,752,755],{},[14,750,5],{}," reacts to ",[45,753,754],{},"user edits",", not to every write that touches storage.",[20,757,758],{},"A handler fires when a real change reaches its source path. Attaform only records a change when a value actually moved (writing the same value back is a no-op), so there is no separate equality check to wire up, and a handler never fires for a write that changed nothing.",[20,760,761,762,765,766,768,769,772,773,775,776,778],{},"Matching is prefix-based in both directions. A write to ",[14,763,764],{},"user.email"," fires a ",[14,767,764],{}," handler and a ",[14,770,771],{},"user"," handler and a whole-form handler. A whole-",[14,774,771],{}," replacement fires a ",[14,777,764],{}," handler too. So a subform handler hears edits to any field beneath it, and a field handler hears the field being replaced wholesale.",[20,780,781],{},"These writes are rebaselines, not edits, so they stay silent:",[626,783,784,794],{},[629,785,786],{},[632,787,788,791],{},[635,789,790],{},"Suppressed write",[635,792,793],{},"Why",[644,795,796,804,815,832],{},[632,797,798,801],{},[649,799,800],{},"Persistence hydration",[649,802,803],{},"Restoring a saved draft on mount is loading state, not editing it.",[632,805,806,809],{},[649,807,808],{},"Cross-tab echo",[649,810,811,812,814],{},"A value arriving from a sibling tab already fired ",[14,813,5],{}," in that tab.",[632,816,817,826],{},[649,818,819,822,823],{},[14,820,821],{},"reset()"," \u002F ",[14,824,825],{},"reset(record)",[649,827,828,829,831],{},"Resetting rebaselines the form; loading data via ",[14,830,825],{}," shouldn't trip an immediate save.",[632,833,834,839],{},[649,835,836],{},[14,837,838],{},"setValue(..., { silent })",[649,840,841],{},"The explicit consumer opt-out (next section).",[35,843,845,846],{"id":844},"opting-a-write-out-with-silent","Opting a write out with ",[14,847,848],{},"{ silent }",[20,850,851,852,855,856,858],{},"Pass ",[14,853,854],{},"{ silent: true }"," to land a value without firing ",[14,857,5],{},". The write is otherwise completely normal: storage, validation, persistence, and history all see it. Only the side-channel is skipped.",[132,860,862],{"className":134,"code":861,"language":136,"meta":137,"style":137},"form.setValue('user.email', saved.email, { silent: true }) \u002F\u002F one path\nform.setValue(savedRecord, { silent: true }) \u002F\u002F whole form\n",[14,863,864,887],{"__ignoreMap":137},[141,865,866,868,871,873,875,878,881,884],{"class":143,"line":144},[141,867,176],{"class":162},[141,869,870],{"class":158},"setValue",[141,872,212],{"class":162},[141,874,247],{"class":215},[141,876,877],{"class":162},", saved.email, { silent: ",[141,879,880],{"class":151},"true",[141,882,883],{"class":162}," }) ",[141,885,886],{"class":268},"\u002F\u002F one path\n",[141,888,889,891,893,896,898,900],{"class":143,"line":166},[141,890,176],{"class":162},[141,892,870],{"class":158},[141,894,895],{"class":162},"(savedRecord, { silent: ",[141,897,880],{"class":151},[141,899,883],{"class":162},[141,901,902],{"class":268},"\u002F\u002F whole form\n",[20,904,905],{},"This is the tool for hydrating the form from a fetched record. Without it, loading ten saved fields would echo ten autosaves straight back at the server you just loaded from. The flag is per call, so the next ordinary edit fires as usual.",[35,907,909],{"id":908},"async-handlers-supersession-and-cancellation","Async handlers: supersession and cancellation",[20,911,912],{},"A handler can be async, and autosave usually is. Dispatch stays synchronous at the write boundary, so the keystroke never waits; the async body runs in the background.",[20,914,915,916,919,920,923,924,927,928,931],{},"When a newer write hits the same source before the previous run finished, Attaform aborts the previous run's ",[14,917,918],{},"ctx.signal"," and supersedes it. Latest write wins. Pass the signal to ",[14,921,922],{},"fetch"," (or check ",[14,925,926],{},"ctx.signal.aborted"," after an ",[14,929,930],{},"await",") so superseded work cancels itself instead of racing the winner to the server:",[132,933,935],{"className":134,"code":934,"language":136,"meta":137,"style":137},"form.onChange('user.email', async (email, ctx) => {\n  await api.save({ email }, { signal: ctx.signal })\n})\n",[14,936,937,967,980],{"__ignoreMap":137},[141,938,939,941,943,945,947,949,952,955,957,959,961,963,965],{"class":143,"line":144},[141,940,176],{"class":162},[141,942,5],{"class":158},[141,944,212],{"class":162},[141,946,247],{"class":215},[141,948,188],{"class":162},[141,950,951],{"class":147},"async",[141,953,954],{"class":162}," (",[141,956,253],{"class":184},[141,958,188],{"class":162},[141,960,191],{"class":184},[141,962,194],{"class":162},[141,964,197],{"class":147},[141,966,200],{"class":162},[141,968,969,972,975,977],{"class":143,"line":166},[141,970,971],{"class":147},"  await",[141,973,974],{"class":162}," api.",[141,976,507],{"class":158},[141,978,979],{"class":162},"({ email }, { signal: ctx.signal })\n",[141,981,982],{"class":143,"line":173},[141,983,225],{"class":162},[20,985,986,987,82],{},"A superseded run's rejection is dropped rather than surfaced, so an aborted save won't fire ",[14,988,717],{},[20,990,991,992,995,996,998,999,1001],{},"To gate the save on the field being valid first, reach back through ",[14,993,994],{},"ctx.form",". This is the seam between the two halves: ",[14,997,77],{}," decides validity, ",[14,1000,5],{}," decides persistence, and you compose them when you want \"save only what passes\":",[132,1003,1005],{"className":134,"code":1004,"language":136,"meta":137,"style":137},"form.onChange('user.email', async (email, ctx) => {\n  const verdict = await ctx.form.validateAsync(ctx.path)\n  if (!verdict.success) return\n  await api.save({ email }, { signal: ctx.signal })\n})\n",[14,1006,1007,1035,1056,1072,1082],{"__ignoreMap":137},[141,1008,1009,1011,1013,1015,1017,1019,1021,1023,1025,1027,1029,1031,1033],{"class":143,"line":144},[141,1010,176],{"class":162},[141,1012,5],{"class":158},[141,1014,212],{"class":162},[141,1016,247],{"class":215},[141,1018,188],{"class":162},[141,1020,951],{"class":147},[141,1022,954],{"class":162},[141,1024,253],{"class":184},[141,1026,188],{"class":162},[141,1028,191],{"class":184},[141,1030,194],{"class":162},[141,1032,197],{"class":147},[141,1034,200],{"class":162},[141,1036,1037,1040,1043,1045,1048,1051,1054],{"class":143,"line":166},[141,1038,1039],{"class":147},"  const",[141,1041,1042],{"class":151}," verdict",[141,1044,155],{"class":147},[141,1046,1047],{"class":147}," await",[141,1049,1050],{"class":162}," ctx.form.",[141,1052,1053],{"class":158},"validateAsync",[141,1055,329],{"class":162},[141,1057,1058,1061,1063,1066,1069],{"class":143,"line":173},[141,1059,1060],{"class":147},"  if",[141,1062,954],{"class":162},[141,1064,1065],{"class":147},"!",[141,1067,1068],{"class":162},"verdict.success) ",[141,1070,1071],{"class":147},"return\n",[141,1073,1074,1076,1078,1080],{"class":143,"line":203},[141,1075,971],{"class":147},[141,1077,974],{"class":162},[141,1079,507],{"class":158},[141,1081,979],{"class":162},[141,1083,1084],{"class":143,"line":222},[141,1085,225],{"class":162},[35,1087,1089],{"id":1088},"errors-never-reach-the-keystroke","Errors never reach the keystroke",[20,1091,1092,1093,1096],{},"A handler that throws or rejects never throws into the write that triggered it. The failure routes to ",[14,1094,1095],{},"options.onError(error, ctx)",", or is swallowed (and logged in development) when no handler is set. The user keeps typing either way.",[132,1098,1100],{"className":134,"code":1099,"language":136,"meta":137,"style":137},"form.onChange('user.email', save, {\n  onError: (error, ctx) => {\n    if (ctx.attempt \u003C 3) ctx.retry()\n    else toast.error('Could not save your email. We will retry on the next edit.')\n  },\n})\n",[14,1101,1102,1115,1134,1156,1173,1177],{"__ignoreMap":137},[141,1103,1104,1106,1108,1110,1112],{"class":143,"line":144},[141,1105,176],{"class":162},[141,1107,5],{"class":158},[141,1109,212],{"class":162},[141,1111,247],{"class":215},[141,1113,1114],{"class":162},", save, {\n",[141,1116,1117,1120,1122,1124,1126,1128,1130,1132],{"class":143,"line":166},[141,1118,1119],{"class":158},"  onError",[141,1121,487],{"class":162},[141,1123,582],{"class":184},[141,1125,188],{"class":162},[141,1127,191],{"class":184},[141,1129,194],{"class":162},[141,1131,197],{"class":147},[141,1133,200],{"class":162},[141,1135,1136,1139,1142,1145,1148,1151,1153],{"class":143,"line":173},[141,1137,1138],{"class":147},"    if",[141,1140,1141],{"class":162}," (ctx.attempt ",[141,1143,1144],{"class":147},"\u003C",[141,1146,1147],{"class":151}," 3",[141,1149,1150],{"class":162},") ctx.",[141,1152,596],{"class":158},[141,1154,1155],{"class":162},"()\n",[141,1157,1158,1161,1164,1166,1168,1171],{"class":143,"line":203},[141,1159,1160],{"class":147},"    else",[141,1162,1163],{"class":162}," toast.",[141,1165,582],{"class":158},[141,1167,212],{"class":162},[141,1169,1170],{"class":215},"'Could not save your email. We will retry on the next edit.'",[141,1172,375],{"class":162},[141,1174,1175],{"class":143,"line":222},[141,1176,515],{"class":162},[141,1178,1179],{"class":143,"line":432},[141,1180,225],{"class":162},[20,1182,1183,1186,1187,1190,1191,82],{},[14,1184,1185],{},"ctx.retry()"," re-runs the handler with the same value and ",[14,1188,1189],{},"attempt + 1",". It is a no-op once a newer write has superseded the run, so a retry never resurrects stale work. Backoff and a retry cap are yours to impose. Keep validation messaging out of here: a rejected save is an infrastructure problem (the network blipped), not a \"this value is invalid\" problem, which stays in ",[14,1192,77],{},[35,1194,1196],{"id":1195},"cleanup","Cleanup",[20,1198,1199,1202,1203,1206,1207,1210],{},[14,1200,1201],{},"form.onChange(...)"," returns an idempotent ",[14,1204,1205],{},"stop()",". Called inside a component's ",[14,1208,1209],{},"setup",", it also stops automatically when that component unmounts, so a handler declared in setup needs no manual teardown:",[132,1212,1214],{"className":134,"code":1213,"language":136,"meta":137,"style":137},"const stop = form.onChange('user.email', save)\n\u002F\u002F call stop() to detach early; otherwise it detaches on unmount\n",[14,1215,1216,1237],{"__ignoreMap":137},[141,1217,1218,1220,1223,1225,1228,1230,1232,1234],{"class":143,"line":144},[141,1219,148],{"class":147},[141,1221,1222],{"class":151}," stop",[141,1224,155],{"class":147},[141,1226,1227],{"class":162}," form.",[141,1229,5],{"class":158},[141,1231,212],{"class":162},[141,1233,247],{"class":215},[141,1235,1236],{"class":162},", save)\n",[141,1238,1239],{"class":143,"line":166},[141,1240,1241],{"class":268},"\u002F\u002F call stop() to detach early; otherwise it detaches on unmount\n",[20,1243,1244,1245,1247],{},"A ",[14,1246,455],{}," handler binds to the form's own lifetime and is released when the form is.",[35,1249,1251],{"id":1250},"on-the-server","On the server",[20,1253,1254,1256,1257,1259],{},[14,1255,5],{}," registration is a no-op during SSR. There is no autosave on the server and no write loop to react to, so it returns a no-op ",[14,1258,1205],{}," and your consumer cleanup stays uniform across server and client.",[35,1261,1263],{"id":1262},"validation-versus-persistence","Validation versus persistence",[20,1265,1266,1268,1269,78,1271,1274,1275,1277],{},[14,1267,5],{}," is the persistence channel. When you want to react to a value by checking it against a server (is this username taken?), that is validation, and it belongs in an async refinement instead, where the verdict lands in ",[14,1270,105],{},[14,1272,1273],{},"handleSubmit"," awaits it before dispatch. Nothing stops you from writing to a server inside a refinement, but a refinement's job is to return a verdict, and folding a side effect into it tangles \"is this valid?\" with \"save this,\" the exact split ",[14,1276,5],{}," exists to keep clean.",[20,1279,1280,1281,1283,1284,1287,1288,1283,1292,1295],{},"Reach for ",[14,1282,5],{}," to ",[45,1285,1286],{},"write"," (autosave, analytics, mirroring). Reach for ",[98,1289,1290],{"href":100},[14,1291,77],{},[45,1293,1294],{},"read"," (server-side validity). Compose them, as above, when an autosave should only fire for a value that passes.",[35,1297,1299],{"id":1298},"where-to-next","Where to next",[50,1301,1302,1312,1320,1332],{},[53,1303,1304,1308,1309,1311],{},[98,1305,1307],{"href":1306},"\u002Fdocs\u002Fcross-cutting-state\u002Fautosave","Autosave",": the production recipe built on ",[14,1310,5],{},", debounced and validity-gated, with per-field and aggregate status.",[53,1313,1314,1317,1318,82],{},[98,1315,1316],{"href":100},"Async refinements",": the reads half, server-side validity through ",[14,1319,77],{},[53,1321,1322,1328,1329,1331],{},[98,1323,1325,1327],{"href":1324},"\u002Fdocs\u002Fwriting-and-mutating\u002Fset-value",[14,1326,870],{}," patterns",": the write surface that carries the ",[14,1330,848],{}," opt-out.",[53,1333,1334,1338],{},[98,1335,1337],{"href":1336},"\u002Fdocs\u002Fcross-cutting-state\u002Fmulti-tab-sync","Multi-tab sync",": another reaction riding the same change tap, across tabs instead of to a server.",[1340,1341,1342],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":137,"searchDepth":166,"depth":166,"links":1344},[1345,1346,1347,1348,1349,1351,1352,1353,1354,1355,1356],{"id":37,"depth":166,"text":38},{"id":109,"depth":166,"text":110},{"id":610,"depth":166,"text":611},{"id":745,"depth":166,"text":746},{"id":844,"depth":166,"text":1350},"Opting a write out with { silent }",{"id":908,"depth":166,"text":909},{"id":1088,"depth":166,"text":1089},{"id":1195,"depth":166,"text":1196},{"id":1250,"depth":166,"text":1251},{"id":1262,"depth":166,"text":1263},{"id":1298,"depth":166,"text":1299},"form.onChange is a side-channel for reacting to partial value changes. Autosave a field, mirror one into another, fire analytics. It runs side effects without ever touching the form's own lifecycle.","md",{},[1361,1364,1366,1368,1370],{"label":1362,"value":1363},"Category","Return method",{"label":110,"value":1365,"kind":14},"onChange(handler) · onChange(source, handler)",{"label":1367,"value":455,"kind":14},"Construction option",{"label":1369,"value":1205,"kind":14},"Returns",{"label":1371,"value":1372},"Side-channel","never marks dirty, touched, or validating","\u002Fdocs\u002Fcross-cutting-state\u002Fon-change",{"title":5,"description":1357},null,"docs\u002Fcross-cutting-state\u002Fon-change","G4Qkkn46SX_zDtx6Xze1ELGlAF0jDtTbQ5LXEk8b-48",1781333215175]