jorgebucaran / superfine

Absolutely minimal view layer for building web interfaces
https://git.io/super
MIT License
1.56k stars 78 forks source link

This again: stateful components? ๐Ÿคจ #164

Closed mindplay-dk closed 4 years ago

mindplay-dk commented 5 years ago

Okay, but bear with me for a minute ๐Ÿ˜

Have you seen this?

https://github.com/wavesoft/dot-dom#3-stateful-component

It's crazy small, but the code is also golfed-to-shit, no JSX support, no keyed updates... Not really what I'm looking for, and I really like your approach to life-cycle hooks much better.

What I want to point out is the functional component signature:

(props, state, setState) => VNode

This is just the most simple and elegant approach I've seen to stateful functional components - like, way cleaner than those silly magical hooks in React.

I love it. I simply can't think of a more obvious way to deal with state in functional components.

Any chance we could try something like this?

jorgebucaran commented 5 years ago

I don't see the thrill of using stateful components like everyone else, not because that approach "isn't worthy of me" or anything like that, but because I want to work on advancing/improving the field of purely functional UIs.

But did you try building something like that with Superfine? What was the problem?

function Clickable(props, state, setState) {
  const { clicks = 0 } = state

  return (
    <button
      onClick={() => {
        setState({ clicks: clicks + 1 })
      }}
    >
      {`Clicked ${clicks} times`}
    </button>
  )
}

render(
  <div>
    <Clickable />
  </div>,
  document.body
)

How would you write the render function?

mindplay-dk commented 5 years ago

I want to work on advancing/improving the field of purely functional UIs.

This approach is functional, isn't it? :-)

How would you write the render function?

I'm looking into it...

The first barrier is the eager evaluation of functional components, which I don't think can work for something like this - you have to know the state when you invoke the function, and you can't know the state until you've created or located the existing DOM element.

jorgebucaran commented 5 years ago

The first barrier is the eager evaluation of functional components, which I don't think can work for something like this...

If you could crosslink with the issue where we discussed this, I'll look into it.

mindplay-dk commented 5 years ago

One lengthy discussion was here, I think we also discussed it over twitter, not sure...

mindplay-dk commented 5 years ago

It doesn't seem like it should be that hard, but I can't get it to work.

Here's what I tried:

I added another constant.

var DEFAULT = 0
var RECYCLED_NODE = 1
var TEXT_NODE = 2
var COMPONENT = 3 // this one

I changed the last lines of the h function to defer functional components:

  var type = typeof name === "function" ? COMPONENT : DEFAULT

  return createVNode(name, props, children, null, props.key, type)

(The name is a bit off now, since name could be either a function or string...)

Then I added this to the top of createElement:

var createElement = function(node, lifecycle, isSvg) {
  if (node.type === COMPONENT) {
    var parent = node;
    var lastNode;
    parent.state = {};
    parent.setState = function (state) {
      parent.state = state;
      patch(lastNode, lastNode = node.name(parent.props, parent.state, parent.setState), node.element);
    };
    node = node.name(parent.props, parent.state, parent.setState);
  }

My thinking here was keep a reference to the parent vnode - the component instance. And replace the node with whatever comes out of the functional component when it gets called... Tack the state and setState functions onto the parent, so we can find them again during updates... somehow.

I didn't get further than that. I thought at this point, I should be able to use something like your example:

function Clickable(props, state, setState) {
  const { clicks = 0 } = state

  return (
    <button onClick={ () => setState({ clicks: clicks + 1 }) }>
      {`Clicked ${clicks} times`}
    </button>
  )
}

patch(
  null,
  <div>
    <Clickable/>
    <Clickable/>
  </div>,
  document.body
)

I figured I should be able to click once, while the original setState handler is attached - but for some reason, even a basic onClick handler like console.log('lol') doesn't work on that element ๐Ÿคจ

I'm obviously overlooking something ๐Ÿ™„

Here's my fiddle.

mindplay-dk commented 5 years ago

Okay, so I got rid of the COMPONENT constant, and instead just manually check for components with typeof name === "function".

The addition to the start of createElement now looks like this:

  if (typeof node.name === "function") {
    var parent = node;
    var lastNode;
    parent.state = {};
    parent.setState = function (state) {
      parent.state = state;
      patchElement(
        lastNode.element.parentElement,
        lastNode.element,
        lastNode,
        lastNode = parent.name(parent.props, parent.state, parent.setState),
        lifecycle, // wrong lifecycle ???
        isSvg
      );
    };
    node = lastNode = node.name(parent.props, parent.state, parent.setState);
  }

And it works ๐Ÿคจ ... for this simple case anyway.

I had to use patchElement rather that patch, which is problematic - this should be a patch operation with a distinct lifecycle state for the component update, but patch still has that 1:1 parent:child relationships I used to complain about.

Of course, I did nothing to patchElement yet - it won't pass state and setState, so triggering an update outside of the "handheld" update I did in setState will break it.

But anyhow, it's fun to see it sort of working ๐Ÿ˜‰

jholster commented 5 years ago

I would love to have this kind of support for local state. It's pragmatic for certain use cases, e.g. open/closed status of dropdown. Just a detail, but how about a signature (props, state) where state is a function which can be used to either read or set state, similar to flyd?

mindplay-dk commented 5 years ago

@jholster I'm not crazy about function overloads.

The API I've been experimenting with myself this weekend has stateful components like this:

function Clock(props, clock) {
  const { time } = clock.state || { time: new Date() }

  setInterval(() => clock.setState({ time: new Date() }), 1000)

  return <div>...</div>
}

In other words, it has an "instance" object that it can optionally provide to functional components, if needed - this instance is associated with the DOM element itself and can maintain state, and could probably support life-cycle events, e.g. clock.onCreate(() => { ... }) etc. - so that the component itself can have an internal life-cycle, independent of the element-level life-cycle events supported by Superfine.

Having everything instance/state-related in a single object should make it possible to compose like you can with React hooks, but in a much more safe and idiomatic way - without a run-time that counts calls and requires a linter to check for unsafe code... since the instance object has the component's identity, state, life-cycle hooks, and everything else pertaining to an occurrence of a component, this should compose nicely by simply passing e.g. these instances to helper-functions - a'la hooks, minus the magic: you'll explicitly pass the instance-object rather than relying on the framework to find it for you.

I'm pretty bad at this, so don't know if I'll be able to pull it off, but I love the idea ;-)

mindplay-dk commented 5 years ago

I didn't make any progress with Superfine, so I decided to start from scratch again ๐Ÿ™„

It sort of works:

https://jsfiddle.net/mindplay/a196yL4w/293/

It has problems though - it kills the state anytime you re-render from above the components.

This demo refreshes for like 5 seconds, then stops, so you can see the counters maintaining state.

I can't make heads or tails of the reconciliation algo in Superfine - my approach is extremely simple, and right now just does a crude reorder with appendChild, but it gets surprisingly accurate element reuse by simple assigning keys to children that don't have any, and so it does support keyed updates, but doesn't have any life-cycle events yet.

I'm honestly close to giving up again ๐Ÿคจ

I think I'll wait for Preact X, which will be out in a week or so - it'll probably be much simpler to just gut it and remove class-based components and other React features...

jorgebucaran commented 5 years ago

Yeah, I think you should do that instead of forcing Superfine to be what is not. I say that with a smile. No harm feelings! Plenty of other VDOMs out there with that one thing in common: stateful components.

I'll never say never, but for a change, it'd be nice if we tried to improve this project on its own and not by making it more like React and derivatives. <3

yisar commented 4 years ago

Halo, have you seen react hooks API?

This is similar to the current version of superfine, but it is stateful

const App = () => {
  const [count, setCount] = useState(0)
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
}

Is there any inspiration here? state does not pass in from outside.

jorgebucaran commented 4 years ago

@132yse How do you think we can implement useState without changes to Superfine?

I'm not a fan of hooks. I'm generally happy with a single state tree, and custom elements, but I'm also curious to see if this can be implemented in userland.

yisar commented 4 years ago

@jorgebucaran This is difficult without changes to Superfine. But I think we can make some small changes, such as:

  1. Keep the name instance instead of executing it immediately, which is where the state is mounted.

    return  {
    name,
    }

    and out of vnode is like this:

    {
    name: f(),
    state: {}
    ...
    }
  2. Need to record current running component.

let current
// sometimes like this:
if(typeof vnode.name==='function'){
  current = vnode
  vnode = vnode.name(vnode.props)
}
  1. Mount state to the current.
    let update = () => {
    // patch...
    }
    let setState = update.bind(current) // current will be this which is changing.
    current.state = state
yisar commented 4 years ago

And I think that single state tree and state component are conflicting, even as state component, it seems that hyperapp do it better.

k1r0s commented 4 years ago

@mindplay-dk I've readed first comments and I guess you can take a look on this: https://github.com/k1r0s/superpie/

It has that kind of API that you're looking for, and it does not change superfine in any way

jorgebucaran commented 4 years ago

@k1r0s Thanks!

Well, closing here! ๐Ÿ‘‹๐Ÿ˜„