<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: wmzy</title>
    <description>The latest articles on DEV Community by wmzy (@wmzy).</description>
    <link>https://dev.to/wmzy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4103046%2F7e23909d-3f72-498a-8a64-1cf0305b45e6.png</url>
      <title>DEV Community: wmzy</title>
      <link>https://dev.to/wmzy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wmzy"/>
    <language>en</language>
    <item>
      <title>One Prop Per State: Rethinking React's Controlled/Uncontrolled Boilerplate</title>
      <dc:creator>wmzy</dc:creator>
      <pubDate>Tue, 01 Sep 2026 04:23:11 +0000</pubDate>
      <link>https://dev.to/wmzy/one-prop-per-state-rethinking-reacts-controlleduncontrolled-boilerplate-2gob</link>
      <guid>https://dev.to/wmzy/one-prop-per-state-rethinking-reacts-controlleduncontrolled-boilerplate-2gob</guid>
      <description>&lt;p&gt;If you've ever wrapped a &lt;code&gt;&amp;lt;Select&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;DatePicker&amp;gt;&lt;/code&gt;, or &lt;code&gt;&amp;lt;Dialog&amp;gt;&lt;/code&gt;, you've written this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setInternal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isControlled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// runtime mode check&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isControlled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// two sources of truth&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isControlled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setInternal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// hand-written sync&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isControlled&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One state: three props, two sources, one sync effect, branches everywhere. A component&lt;br&gt;
with three controllable states (&lt;code&gt;value&lt;/code&gt;, &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;selectedIndex&lt;/code&gt;) needs &lt;strong&gt;9 props,&lt;br&gt;
3 branches, 3 effects&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I solved this with &lt;a href="https://github.com/wmzy/react-use-control" rel="noopener noreferrer"&gt;react-use-control&lt;/a&gt; — an&lt;br&gt;
80-line, zero-dependency hook that works differently from the usual approaches.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ask a different question: where does the state come from?
&lt;/h2&gt;

&lt;p&gt;The controlled/uncontrolled duality exists because we ask "does the parent control this&lt;br&gt;
state?" and branch. The real question is simpler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a parent already created the state → use it.&lt;/li&gt;
&lt;li&gt;If nobody did → create it locally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No second source means no synchronization, and no mode detection. That's what a control&lt;br&gt;
object does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setNum&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// count is a control, or a plain value&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setNum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Pass a plain value → uncontrolled; that value is the default.&lt;/li&gt;
&lt;li&gt;Pass a control → controlled; parent and child share one source of truth.&lt;/li&gt;
&lt;li&gt;Both modes run the &lt;strong&gt;same code path&lt;/strong&gt; — zero branching inside the component.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  This is not signals
&lt;/h2&gt;

&lt;p&gt;It looks like Solid/Jotai — a token passed around. The mechanism is the opposite:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Signals (Solid/Jotai)&lt;/th&gt;
&lt;th&gt;react-use-control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where state lives&lt;/td&gt;
&lt;td&gt;Outside the tree — a module-level store&lt;/td&gt;
&lt;td&gt;Inside the tree — a plain &lt;code&gt;useState&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How updates flow&lt;/td&gt;
&lt;td&gt;Subscription + fine-grained re-render&lt;/td&gt;
&lt;td&gt;React's normal setState flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Global; leaks are your problem&lt;/td&gt;
&lt;td&gt;Dies with the owning component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module-scope state creation&lt;/td&gt;
&lt;td&gt;Yes — the whole point&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A control is not a container of state. It's a &lt;strong&gt;locator&lt;/strong&gt;: "the &lt;code&gt;useState&lt;/code&gt; someone in my&lt;br&gt;
ancestry created — or the one I create right now." React stays the store and the&lt;br&gt;
scheduler, so SSR, StrictMode, and concurrent rendering work for free. No subscriptions,&lt;br&gt;
no cleanup, no leak footguns. The one cost: state can't exist without a mounted component.&lt;/p&gt;
&lt;h2&gt;
  
  
  Freebies: sibling sharing and middleware
&lt;/h2&gt;

&lt;p&gt;The same control passed to multiple children shares one state — no Context, no lifting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countCtl&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;countCtl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;countCtl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* both share state with the parent */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Middle layers transform state without owning it. &lt;code&gt;useThru&lt;/code&gt; removes the wrapper's own&lt;br&gt;
state entirely — the child stays the owner and the trigger, the wrapper contributes only&lt;br&gt;
the transform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useThru&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mapSetter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;  &lt;span class="c1"&gt;// clamp writes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useThru&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mapState&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;          &lt;span class="c1"&gt;// map reads&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useThru&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;                 &lt;span class="c1"&gt;// observe writes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;value&lt;/code&gt;/&lt;code&gt;onChange&lt;/code&gt; equivalent of this chain is a parent-owned state and a&lt;br&gt;
hand-wrapped callback at every level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks (jsdom, identical DOM output)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;manual triple&lt;/th&gt;
&lt;th&gt;Radix &lt;code&gt;useControllableState&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;react-use-control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mount (uncontrolled)&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;1.31×&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.38×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;controlled prop update&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;2.26×&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.67×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;setter update (click)&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;1.09×&lt;/td&gt;
&lt;td&gt;tied (0.99×)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The controlled-update path is where the architectures diverge: manual and Radix both keep&lt;br&gt;
a second source to sync; a control reads the single source directly. The setter path is&lt;br&gt;
dominated by React's own dispatch and is a tie — reported honestly, not cherry-picked.&lt;br&gt;
Reproduce with &lt;code&gt;pnpm bench&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;p&gt;Fields managed by a form library (react-hook-form, formik, …) — the form owns that&lt;br&gt;
state, the field is permanently controlled, so read and write through the form's API.&lt;br&gt;
Control solves the ownership problem; where the problem doesn't exist, it adds nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One prop per state: &lt;code&gt;Control&amp;lt;T&amp;gt; | T&lt;/code&gt; — a value is the default, a control is sharing&lt;/li&gt;
&lt;li&gt;Single source of truth: no sync effect, no &lt;code&gt;isControlled&lt;/code&gt; branch, no
uncontrolled→controlled warning&lt;/li&gt;
&lt;li&gt;80 lines, 0 deps, built on &lt;code&gt;useState&lt;/code&gt;/&lt;code&gt;useMemo&lt;/code&gt;/&lt;code&gt;useRef&lt;/code&gt; only&lt;/li&gt;
&lt;li&gt;Powers &lt;a href="https://github.com/wmzy/haze-ui" rel="noopener noreferrer"&gt;haze-ui&lt;/a&gt;, a component library where every
stateful component takes one prop per state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/wmzy/react-use-control" rel="noopener noreferrer"&gt;react-use-control&lt;/a&gt; · &lt;code&gt;npm i react-use-control&lt;/code&gt;&lt;br&gt;
Deep dive: &lt;a href="https://github.com/wmzy/react-use-control/blob/master/docs/blog/state-ownership-in-react.md" rel="noopener noreferrer"&gt;Who Owns the State?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>hooks</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
