A Proposal for a New State Management Method to Drastically Simplify Frontend Framework State Management
!!! ATTENTION !!!
This proposal aims for near-zero boilerplate but does not consider specific frontend framework implementations.
Want to see it in action? Check out the prototype repository or try the demo.
I will explain step by step.
Make State Management Class-Based.
State management is class-based, and state is managed with properties.
All state updates are done via assignment, detected by a Proxy's set
trap, which issues update triggers. If there are multiple state updates, efficiency is improved by batching, such as accumulating updated properties and executing them together.
class State {
count = 0;
increment() {
this.count = this.count + 1;
}
}
class StateHandler {
set(target, prop, value, receiver) {
try {
return Reflect.set(target, prop, value, receiver);
} finally {
trigger(prop, value); // Update trigger
}
}
}
proxy = new Proxy(new State, new StateHandler);
Accessing Data with Hierarchical Structures.
Access to state with hierarchical structures is done using full paths.
Enforcing full paths creates a single point of access that can be reliably trapped by a Proxy. The set
trap can detect updates at the granularity of individual paths. Complex multi-level Proxies become unnecessary.
Accessing full paths is parsed by a get
trap to access the corresponding data element.
Note: The get
trap shown here is conceptual and does not represent the actual implementation. For getters, speed is improved through caching, path parsing caches, and pre-analysis.
class State {
user = {
profile: {
name: "Alice",
email: "alice@domain",
}
}
changeName(name) {
this["user.profile.name"] = name; // trigger("user.profile.name")
}
}
class StateHandler {
get(target, prop, receiver) {
const segments = prop.split(".");
if (segments.length > 1) {
// access by fullpath
const lastName = segments.pop();
const parentProp = segments.join(".");
return this.get(target, parentProp, receiver)[lastName];
} else {
return Reflect.get(target, prop, receiver);
}
}
}
Enforce Full Paths in the UI as well.
In the UI, too, descriptions use full paths, just like the state.
This makes the things pointed to by the UI and the state consistent via full paths.
This means that when a change to a state's full path is detected, the UI can be updated precisely.
Conversely, it becomes possible to notify changes from the UI update back to the state's full path.
As a result, the structure of the UI and the state becomes consistent.
{{ user.profile.name }}
{{ user.profile.email }}
class State {
user = {
profile: {
name: "Alice",
email: "alice@domain",
licenses: [ "driving", "US CPA" ],
}
}
changeName(name) {
this["user.profile.name"] = name; // trigger("user.profile.name")-> UI:{{ user.profile.name }}
}
}
function trigger(path, value) {
// Identify and update UI nodes
}
UI Limitations and Syntax
To match the structure of the UI and state, a custom syntax is more suitable because views are better if they are simple to parse.
Note: JSX allows flexible HTML description, making it difficult to handle and less suitable.
With repetition, conditional branching, embedding, and attribute binding, I believe the approximate UI structure can be represented.
Full paths can be specified for each of these.
{{ for:user.profile.licenses }}
{{ endfor: }}
{{ if:user.isLogin }}
{{ else: }}
{{ endif: }}
{{ user.profile.name }}
data-bind="disabled:user.isLogout">
Derived State
You can create derived state using getters in the state class.
During execution, the full paths ("user.profile.name") referenced inside the getter can be obtained, allowing dependency tracking. If the referenced full path ("user.profile.name") is updated, the derived state can be updated automatically.
In the UI, too, it can be accessed like a regular full-path property.
{{ user.profile.name }} {{ user.profile.ucName }}
class State {
user = {
profile: {
name: "Alice",
email: "alice@domain",
licenses: [ { name:"driving" }, { name:"US CPA" } ],
}
}
get "user.profile.ucName"() {
return this["user.profile.name"].toUpperCase();
}
}
Lists
For full paths to lists, an asterisk is used.
Index variables within loops are implicitly provided as $1, $2, etc., according to the loop nesting.
They can be used in repetition, conditional branching, embedding, and attribute binding.
If the loop context is the same, an asterisk is used in the path instead of $1.
Changes to lists are made by immutably generating a new array and assigning it. During changes, list differences are detected to consider performance.
{{ for:user.profile.licenses }}
index={{ $1 }}
{{ user.profile.license.*.name }}
data-bind="onclick:delete">delete
{{ endfor: }}
class State {
user = ...
delete(e, $1) {
this["user.profile.licenses"] = this["user.profile.licenses"].toSpliced($1, 1);
}
}
Derived State for List Elements
Derived state for list elements can be created using getter properties.
If the loop context is the same, access using an asterisk ("user.profile.license.*.name") is possible, allowing for a highly declarative description using an abstract path instead of a specific property.
Also, like regular derived state, dependency tracking is possible.
Access from the UI side can be described the same way as regular properties.
{{ for:user.profile.licenses }}
index={{ $1 }} {{ user.profile.license.*.ucName }}
{{ endfor: }}
class State {
user = {
profile: {
name: "Alice",
email: "alice@domain",
licenses: [ { name:"driving" }, { name:"US CPA" } ],
}
}
get "user.profile.license.*.ucName"() {
return this["user.profile.license.*.name"].toUpperCase();
}
}
Maintainability
Because both the UI and state are written with full paths, the scope of influence can be easily isolated by searching for paths.
You just need to search for the same full path in both the UI and state.
Locations with dependencies are concentrated in getters, making management easier.
Reduced Cognitive Load
Because both the UI and state are written with full paths and refer to the same structure, less context switching is required, which is expected to significantly reduce cognitive load.
Team Development
Because of full paths, all developers on the team can understand that they are pointing to the same thing, which may eliminate discrepancies in information sharing.
Drawbacks
It selects applications that can be adapted because it requires the UI and structure to be consistent.
However, by skillfully using derived state like getters, this limitation might be mitigated.
Based on the above, by introducing a common language of full paths and linking the UI and state with them, much boilerplate and many state hooks become unnecessary, enabling the practice of very simple state management.
Below application samples:
width="200" height="200">
data-bind="attr.points:points">
cx="100" cy="100" r="80">
{{ for:stats }}
data-bind="
attr.x:stats.*.labelPoint.x;
attr.y:stats.*.labelPoint.y;
">{{ stats.*.label }}
{{ endfor: }}
{{ for:stats }}
{{ stats.*.label }}
type="range" data-bind="value|number:stats.*.value" min="0" max="100">
{{ stats.*.value }}
data-bind="onclick:onRemove" class="remove">X
{{ endfor: }}
id="add">
name="newlabel" data-bind="value:newLabel">
data-bind="onclick:onAdd@preventDefault">Add a Stat
polygon {
fill: #42b983;
opacity: 0.75;
}
circle {
fill: transparent;
stroke: #999;
}
text {
font-size: 10px;
fill: #666;
}
label {
display: inline-block;
margin-left: 10px;
width: 20px;
}
#raw {
position: absolute;
top: 0;
left: 300px;
}
<span class="na">type="module">
export function valueToPoint(value, index, total) {
const x = 0;
const y = -value * 0.8;
const angle = ((Math.PI * 2) / total) * index;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const tx = x * cos - y * sin + 100;
const ty = x * sin + y * cos + 100;
return {
x: tx,
y: ty
};
}
export default class {
newLabel = '';
stats = [
{ label: 'A', value: 100 },
{ label: 'B', value: 100 },
{ label: 'C', value: 100 },
{ label: 'D', value: 100 },
{ label: 'E', value: 100 },
{ label: 'F', value: 100 }
];
get "stats.json"() {
return JSON.stringify(this.stats);
}
get "stats.*.labelPoint"() {
return valueToPoint(100 + 10, this.$1, this.stats.length);
}
get "stats.*.point"() {
return valueToPoint(this["stats.*.value"], this.$1, this.stats.length);
}
get points() {
const points = this.$getAll("stats.*.point", []);
return points.map(p => `${p.x},${p.y}`).join(" ")
}
onAdd(e) {
if (!this.newLabel) return;
this.stats = this.stats.concat({ label: this.newLabel, value: 100});
this.newLabel = '';
}
onRemove(e, $1) {
if (this.stats.length > 3) {
this.stats = this.stats.toSpliced($1, 1);
} else {
alert("Can't delete more!");
}
}
}
class="container">
class="table table-striped">
class="col-md-3">
class="col-md-3">
class="col-md-2">
class="col-md-2">
class="col-md-2">
class="text-center">State
class="text-center">Capital City
class="text-center">Population
class="text-center">Percent of Region's Population
class="text-center">Percent of Total Population
{{ for:regions }}
{{ for:regions.*.states }}
class="text-center">{{ regions.*.states.*.name }}
class="text-center">{{ regions.*.states.*.capital }}
class="text-right" data-bind="
class.over : regions.*.states.*.population|ge,5000000;
class.under: regions.*.states.*.population|lt,1000000;
">{{ regions.*.states.*.population|locale }}
class="text-right">{{ regions.*.states.*.shareOfRegionPopulation|percent,2 }}
class="text-right">{{ regions.*.states.*.shareOfPopulation|percent,2 }}
{{ endfor: }}
class="summary">
class="text-center" colspan="2">{{ regions.* }}
class="text-right">{{ regions.*.population|locale }}
class="text-right">{{ regions.*.shareOfPopulation|percent,2 }}
{{ endfor: }}
class="summary">
class="text-center" colspan="2">Total
class="text-right">{{ population|locale }}
body {
margin-left: 10px;
}
.over {
color: red;
}
.under {
color: blue;
}
tr.summary td {
background-color: white;
font-weight: bold;
}
<span class="na">type="module">
import { allStates } from "states";
const summaryPopulation = (sum, population) => sum + population;
export default class {
stateByRegion = Map.groupBy(allStates, state => state.region);
regions = Array.from(new Set(allStates.map(state => state.region))).toSorted();
get "regions.*.states"() {
return this.stateByRegion.get(this["regions.*"]);
}
get "regions.*.states.*.shareOfRegionPopulation"() {
return this["regions.*.states.*.population"] / this["regions.*.population"] * 100;
}
get "regions.*.states.*.shareOfPopulation"() {
return this["regions.*.states.*.population"] / this.population * 100;
}
get "regions.*.population"() {
return this.$getAll("regions.*.states.*.population", [ this.$1 ]).reduce(summaryPopulation, 0);
}
get "regions.*.shareOfPopulation"() {
return this["regions.*.population"] / this.population * 100;
}
get population() {
return this.$getAll("regions.*.population", []).reduce(summaryPopulation, 0);
}
}
Your Feedback Is Welcome!
I've shared an idea for a new state management approach. This is still an evolving concept, and I believe it can be further improved with the wisdom of the community.
- Are you interested in this approach?
- Would you like to try implementing it, or have you tried something similar before?
- Do you have concerns about performance or scalability?
- How could this idea be further developed?
Please share your thoughts in the comments. If you try out this idea, I'd love to hear about your experience. If you have PRs or implementation examples, please share links to your repositories!
Let's make frontend development better together.