Open orenelbaum opened 2 years ago
well I was thinking about this and also why I was a little skeptical of adding the function level scope for the mutable state, I do have an idea that I can look into, I'll update with the example of the working example for this, since the passed reference would need to be followed around the app and that actually moves away from just being a babel transform.
Will update the thoughts and example here as I progress
I'm not sure if tracking mutable state around your code base is practical. It is the ideal solution but will probably be very hard to implement, and you will probably need to introduce restrictions since JS is too dynamic. There are two ways I can think about to solve it without cross file analysis:
Both of those solutions can work with an approach where you treat every identifier prefixed with $ as mutable state, at least when it's an object property or function argument. This way in combinations with one of those solutions I mentioned you can easily get the value setter pair of a state and pass it around. As long as you pass it around in identifiers prefixed with $, the receiving function will be able to recognize it without cross file or even cross function analysis.
This plugin is an example for a plugin that doesn't prefix variables, so it has to convert the mutable state variable (they call it ref) to a getter setter pair before transferring it and then convert it back to a mutable state variable after receiving it. This is a plugin for (SolidJS)[https://www.solidjs.com/] and not for React but the same logic holds there.
Marko is an example of a framework with it's own language that does something kind of similar with a new syntax they added recently. It utilizes cross file analysis, but it also uses a DSL that is easier to analyze than JSX.
I was going with a .value()
solution , which is similar to what you just said but I guess I can shorten it down to just being a function execution instead , sounds like a good solution , I'll reconsider anything that might get complex and probably release a next
tagged version with the same.
i still think the verbose would be easier on both the user and the plugin considering the amount of control the dev wishes to transfer around but yes, there's definitely cases where I'd want it to be handled by the plugin instead of me having to write the setters again
This is what I've been able to achieve right now , I'm guessing this is what you were talking about
do let me know if I'm off on the understanding
function useCustomHook() {
let $x = { name: "reaper" };
const addAge = () => {
$x = {
...$x(),
age: 18,
};
};
return [...$x, addAge];
}
const Component = () => {
let [x, setX, addAge] = useCustomHook();
const updateName = () => {
setX({
...x,
name: "name",
});
};
return (
<>
{x.name}
{x.age}
<button onClick={updateName}>update</button>
<button onClick={addAge}>addAge</button>
</>
);
};
// ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
function useCustomHook() {
const [x, setX] = React.useState({
name: "reaper",
});
const addAge = () => {
setX({ ...x, age: 18 });
};
return [...[x, setX], addAge];
}
const Component = () => {
let [x, setX, addAge] = useCustomHook();
const updateName = () => {
setX({ ...x, name: "name" });
};
return (
<>
{x.name}
{x.age}
<button onClick={updateName}>update</button>
<button onClick={addAge}>addAge</button>
</>
);
};
Edit: Updated with a better example
This is what I was talking about (well there was more to this idea, I also had ideas around working with those declarative variables on the receiving end as I'm going to show) but looking more into this approach I don't know if it can work with TS so I have to rethink everything now. I assume that you also probably don't want to break TS.
So this is my fallback idea (have to look more into it too, maybe there's also problems there but at least I made sure this time that this basic example works without breaking TS):
First approach - remove automatic state creation (this is my preferred approach):
function useCustomHook() {
// Vanilla JS
let $x = useState({ name: "reaper" });
// TS
let $x = state({ name: "reaper" });
const addAge = () => {
$x = {
...$x,
age: 18,
};
};
return [ref($x), addAge];
}
const Component = () => {
let [$x, addAge] = useCustomHook();
const updateName = () => {
$x = {
...$x,
name: "name",
};
};
return (
<>
{$x.name}
{$x.age}
<button onClick={updateName}>update</button>
<button onClick={addAge}>addAge</button>
</>
);
};
// ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
function useCustomHook() {
let $x = useState({ name: "reaper" });
const addAge = () => {
$x[1]({
...$x[0],
age: 18,
});
};
return [$x, addAge];
}
const Component = () => {
let [$x, addAge] = useCustomHook();
const updateName = () => {
$x[1]({
...$x[0],
name: "name",
});
};
return (
<>
{$x[0].name}
{$x[0].age}
<button onClick={updateName}>update</button>
<button onClick={addAge}>addAge</button>
</>
);
};
This approach generalizes the concept of what I like to call "reactive variables" (like mutable state but can be used with any getter setter pair). When you declare a variable prefixed with $
and initialize it with a value setter pair it will create a reactive variable out of this pair. The reason we need a special function in the TS case instead of calling useState
directly is just for types, we want it to have the type T
instead of [T, (val: T) => void]
.
I'm using a compile-time function ref($x)
to get the value setter pair instead of the value.
The reason I get rid of automatic useState
calls is because I want to reserve this syntax for the more general reactive variable syntax, and creating state is a more specific case.
The second approach will be almost the same but keeping the automatic useState
calls and instead using a special syntax to create reactive variables without creating new state:
function useCustomHook() {
let $x = { name: "reaper" };
const addAge = () => {
$x = {
...$x,
age: 18,
};
};
return [ref($x), addAge];
}
const Component = () => {
let [x, addAge] = useCustomHook();
let $x = $(x)
const updateName = () => {
$x = {
...$x,
name: "name",
};
};
return (
<>
{$x.name}
{$x.age}
<button onClick={updateName}>update</button>
<button onClick={addAge}>addAge</button>
</>
);
};
I'm using here $
as a compile-time function that creates a reactive variable without useState
.
This approach is similar to the (SolidJS plugin)[https://github.com/LXSMNSYC/babel-plugin-solid-labels] I shared earlier.
You can maybe make this specific example more concise if you decide destructuring assignment creates a reactive variable without creating state. This will enable you to keep the let [$x, addAge] = useCustomHook();
syntax.
Something that will work with both of those approaches is treating function params and object properties as reactive variables if they are prefixed with $
. In this case it's the responsibility of whoever is calling the function or creating the object to pass a value setter pair to the function or assign a value setter pair to the object.
e.g.
function($x) {
$x = $x + 1
}
// ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
function($x) {
$x[1]($x[0] + 1)
}
Again I'm not sure that any of this will actually work as I'm still in the early exploration stages myself, but I did have this idea cooking in my head for a while now and I've been meaning to make my own plugin.
I do have the example I shared working already, on the mentioned PR. ill try the other 2 as well, but i still think the verbose one in the existing plugin is more graceful this will need additional docs but I’m open to try to create these anyways, shouldn’t be hard
I’ll check the TS example as well
I also prefer the approach where you call your mutable state variables to get the value, but I don't think that it can work with TS.
I had an idea. If you do go with the API where accessing mutable state by default gives you the value and not the reference ($state
instead of $state()
to get the value), I was thinking that maybe you can add the option to pass the reference by assigning to another $
prefixed identifier.
What I mean by that is by doing something like { $x: $y }
$y
will be passed as a reference instead of a value, because we are assigning it to the prefixed identifier $x
. This can also work with function params and props.
But I was thinking that it wasn't enough, because if you want to return a reference from a function you would need something like a compile-time function to get the reference for a mutable state like ref($state)
. What I just realized is that while I do want to keep this compile-time function, I think that most of the time you won't need it because you can pass mutable state around in objects, and with this syntax I showed it's pretty clean.
For example if you have a mutable state $state
and you want to return it from a function, you can do return { $state }
instead of return ref($state)
which is a much nicer syntax IMO.
I actually really like this idea, and it makes me much more excited about this approach (the approach of accessing value by default and not reference, which works with TS).
so the current syntax should already be handling this? other than the setter , so just adding a way to reveal the setter would make sense or I could clone the $x[1](x)
from the above example.
On the other hand,
Problem statements right now
Feel like, both cross the scope of a babel transform plugin and reach a mini syntax compiler
let's see what I can get done without having to follow around the reference and still keep it clean and easy to use
What are you referring to when you say the current syntax? The syntax where you get the value by calling the mutable state? This syntax won't work with TS, at least I didn't manage to make it work.
I missed one detail, I'll get to it in a bit, but other than this detail the Syntax I showed in my last comment doesn't need cross-file analysis. That's why I'm glad I came up with this idea, cause I've been thinking about this issue for a while and this is the nicest way I found to pass around data without cross cross-file analysis which also works with TS.
It is maybe slightly more complicated but it's still relatively simple, there are two basic ideas here:
$
annotation not only to create state, but also to annotate object properties destructured function params (and props by extension). That's the part that prevents the need for cross-file analysis. If an object property or a destructured property is prefixed we will expect those properties to contain a value-setter pair, even if the objects are passed from another scope. Whenever we access a property prefixed with $
we know that it needs to have a value setter pair so we cant treat it as a mutable state variable. That's also why unlike the Solid plugin, we don't need to opt-in to a mutable state behavior again manually after receiving the value. In this case, the prefixed params or properties are a sign for the compiler to automatically opt-in.$state
(not $state()
) syntax to get the value. Unless we are assigning it to a prefixed object property or a prop which is basically a property on a function param. This is the part that let's us skip the ref
function or whatever snytax you use to opt out, again like the Solid plugin also does. Because assigning it to a prop or an object property is telling the compiler to pass by reference.Those are the two basic ideas you need to make this work. In my example I threw in a few more things, like you can prefix function params themselves (not the properties). What I missed is that if I have a function for example function func($x) {...}
, i can't just call the function like this func($x)
, cause in this case I don't signal to the compiler that I want to pass by reference.
So instead the solution is to make the function this way function func({ $x }) {...}
, and call it like this func({ $x })
, this way the compiler will notice that we are assigning it into a prefixed property so it will know to pass it by reference (again no cross-file analysis, we are assigning it to the property $x
in the object literal).
I don't think that I'm doing a great job of explaining it but I think that with a proper tutorial/docs this shouldn't be really complicated to learn. Other approaches could be slightly easier to learn but they won't have as nice a syntax or won't work with TS. At least not the ones I've seen or thought about.
What are you referring to when you say the current syntax?
The one the plugin already handles , $state
to get data and $state = something
to change state and creating a manual setter when needed.
I like the analogy, but I guess we could move it to a different plugin altogether than making this one complex, and the theory you have here checks out with the spec of the language and typescript so I would like to explore the outcome and ease of use, I'll create a clone repo of the plugin specifically to play around with this idea
That's a good idea. I'm still very much playing around and in the brainstorming phase. I've been thinking about this problem for a long time now but I keep changing ny preffered syntax all the time.
I've been thinking about this problem for a long time now but I keep changing ny preffered syntax all the time.
that explains the detailed level of information that you have about the issues/pitfalls with each syntax
you can track the progress on https://github.com/barelyhuman/mute , It's a modified clone of this repo , I'll be making changes as needed
It seems that right now there isn't a convenient way to pass mutable state outside of the component or hook, i.e. return it, pass it to a function or pass it to children. The only way to get the setter is by creating a closure
val => myState = val
. So if I want to pass around mutable state (read and write) I need to get the setter, then I need to pass both the setter and the value around. This is a little verbose, and also wherever I pass the mutable state to I'll have to deal with two variables again (value and setter) instead of one. I think that it could be nice if there was a better way to get the setter and pass around mutable state.