barelyhuman / babel-plugin-mutable-react-state

(WIP) transform mutable variables to react-state
https://barelyhuman.github.io/babel-plugin-mutable-react-state/
MIT License
7 stars 0 forks source link

Pass state outside of component scope #5

Open orenelbaum opened 2 years ago

orenelbaum commented 2 years ago

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.

barelyhuman commented 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

orenelbaum commented 2 years ago

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:

  1. Add a compile time function that takes mutable state and returns a value setter pair instead of just getting the value.
  2. Make a special syntax for getting the value of a state (e.g. calling your mutable state like it's a function. Then the plugin switches the calls with the actual value. This is the solution I'm exploring for my own plugin. A little bit weird but short). Now when you reference a state by default you get a value setter pair unless you use the special syntax to access the value.

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.

barelyhuman commented 2 years ago

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

barelyhuman commented 2 years ago

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

orenelbaum commented 2 years ago

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.

Also, I made a TS playground example for the first approach - https://www.typescriptlang.org/play?#code/CYUwxgNghgTiAEAzArgOzAFwJYHtXwGcMoMQAeAFQD4AKANyggC54KBKFigWAChRJYCFOmx54cRJVoNmrDq168MATwAOCAAogYBMQF54Ab1RQAtiBZEYWVAHMANPCi2QAfhapkpgEbb4AX0UeYUxcfGQCEABhCIwcUwAJHBwAaxp5AG0tHTxHdPg9Kng6HCxgAF0jXnh4CBAMeAASAA8CwmJSMmzdVFpDeBNzFgAiOCh1GGGAtgBuIJqwPCInYGAAQRc2-MKqnhqalrbDav2agDoLlvsT0+cLeABGAA5rvf3-ObePk7gMZBh8BkJDQWmxHFBVhsQOVPoEeLxFqhllF4qo8CBUA0DNsisc3nUGhkrit1i5KgYItFYvEkql0p8FksGshVMASCAAHJmBDYtgFXE3JqtAx407nS7NV5igbckaDEDDKXvBkBFW-f74GiCshUQU1QwtM7yuHSg3NM53E1isjeZAYOL4PBRCBYMApPSGFls0hc8z+Khe9lkAD0tvteF1b1ONrtDvgTpdbo9ENJIH9KahIbDDsj0eDufgs14HyAA

barelyhuman commented 2 years ago

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

barelyhuman commented 2 years ago

I’ll check the TS example as well

orenelbaum commented 2 years ago

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.

orenelbaum commented 2 years ago

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 this is the syntax I have in mind right now: https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAJQKYEMDG8BmUIjgcilQ3wFgAoCgeirgBUALYAZzhbgCsBXZ+ZpGFzBxM0ODAZI4RXnAiZxkuEgAeKcABskFNBAB2slXAC8cAIwUt8ACRF0MYADckANRRQT5nftmOUGzwtyXQM+ARgkD1MAChUALjg9LhAAI0iAShMAPjgAbwBfalpGKQADa1KRLj0MYH02Vj0kJAATVrk9DQBPcQg4EOdYRSkYLrApFD0WhukkEAhnaZR4XXBgLXFQbXI2tA13KUxq2vrrAB46LNiEgG06ABo4a-p04yzHCGAWgF10hLpvKE4ABZLoAYVwwhi8TycFsxAczjcUAA-AkkqlIo94fYnEgAAo4MBoxLJNIefKZN5wM5ZM5ULIUXb7IhVGoOepETAXK4wuh-egUIXkGj0JisdgSCYYLj+ZRqTTbIpwMF2CKzXHOOB+KDAFApLTMSwCOG8ZZSUxGODWm22u32h2Op2iqymmDmzxoNVIADK7oisXSxpsdlqSPcZk8OLDrncTvjjpdJujiNjUEjphTeORwbhodTyIATFHojc-BpHvwYBEoL8E-XrUmQwjs+5i6Yy-5K+FIt9c7k8y3w1AAMxwfKeAdZ4cjhLWUvl7vV3uZCeN2iuqf51uj8eTweatOzuCditwKs177jwGyCApDj76dpucLrvnnu11cN22ikK3++Ptuw63Iu77LrW17BD4WDHBmTxPsi6Jkhk2R5Gu371r+0FsmgUZAWmqEAHTETeME1O2TxbkOBH5AkVGHohpKYhSVI5AU9pYUCRw1J40T0TGyLjqxcDEYRFBnKCELgHkon8QWcb5BODIANwYZhtASeCkIycRck7uOSlZMp4mSdpT6EhAYDGLkCHuIZqlqXAoqaVJwjmUS1m2VA9kmVp0nuZZnmvmeF4rvZanOaZ-n4RZVm5KeS6Xj5lAirQACq-AajG2ruHqBpIEa5BeY58ail5NwAAx9kV+GCZaJV2mVtXuDcZjfIGFDKXxB4CQpmQNU5tDdcVlIUFy0Ref1A1NdRObkKFUATc1UBTRFtDlW1wp3hwhHFQNHG0Ntu3LZV1VHcV9X7YNcj3sds0tW1HWpXAACCUznn0+iicKf5kbhMReUhzHCbkFDWntV02jNDEtVVYM9fJURwFa+3Q71UCte1KhBuQ66ve9zCfXo30pdaRCCFAeiwiNa2zBTVN6cOkGFOQQA

barelyhuman commented 2 years ago

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,

  1. yes, if passed around in an object , it'll be easier to track the reference but I'll still have to track the reference accross files to be sure about it
  2. still adds a layer of abstraction that would need explanation to make it clearer

Problem statements right now

  1. Track referenced mut state variables around the bundle / multiple files
  2. Prepare a syntax to avoid collision on the above and keep it easy to learn

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

orenelbaum commented 2 years ago

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:

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.

barelyhuman commented 2 years ago

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

orenelbaum commented 2 years ago

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.

barelyhuman commented 2 years ago

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