zouloux / prehook-proof-of-concept

This is a proof of concept of a React Hook like implementation for Preact.
MIT License
33 stars 2 forks source link

Thoughts #1

Open danielkcz opened 5 years ago

danielkcz commented 5 years ago

From all those proposals from RFC this one definitely looks the tidiest :) I am curious how you would approach custom hooks? In my opinion, these are what makes React hooks the most brilliant - ability to separate and share encapsulated behaviors.

mcjazzyfunky commented 4 years ago

@zouloux Of course, you are right that maybe I am "too much int the React syntax". But there are wo things that IMHO everybody should be aware of:

mcjazzyfunky commented 4 years ago

@zouloux Regarding Svelte: There is no doubt that if someone provides a way to write components and apps in less lines of code and the output bundles are also much smaller and the app may be much faster then for sure this person deserves really all my respect (and btw the author is undoubtably a really smart guy ... and IMHO has a really great sense of humor - I really love that guy).

But this "reactive declarations" feature (https://svelte.dev/examples#reactive-declarations) which uses syntactical valid JavaScript code but changes its semantics entirely is a complete deal breaker for me, and frankly, statements like the following are not really convicing me:

Don't worry if this looks a little alien. It's (if unconventional) JavaScript, which Svelte interprets to mean 're-run this code whenever any of the referenced values change'. Once you get used to it, there's no going back.

mindplay-dk commented 4 years ago

@mindplay-dk Thanks for the demo. I have some question regarding this:

1.) Let's say you want the counter to have a default value of 100 and a default title of 'Counter'.

I personally prefer declaring my defaults inline, where they're needed - line 215 shows how to initialize with a default value of 0:

  const [state, setState] = counter.useState(({ value }) => ({
    count: value || 0
  }));

2.) In line 224: Let's say you also want to print out the title of the counter. Would you do the following or what else would you do?

console.log(`You clicked "${counter.props.title}" ${state.count} times`)

Yep, counter is the Component instance, which has the current props.

That's not super elegant, but this is just an experiment / proof-of-concept.

3.) Let's say you want to write a hook function useWindowSize what TypeScript type would that function have? Same question for a hook functions that returns a bundle of 10 different things.

Custom hooks just need to accept the component instance as an argument, so:

function useWindowSize(component) {
  component.useState(...);
  component.useEffect(...);
  // etc.
}

And then apply it with:

const Something = component(counter => {
  useWindowSize(counter, () => {
    // something
  });
});

Again, this isn't super hot, with custom hooks having a different signature from the built-in hooks... There's probably better way to do this, so yeah, it's just proof-of-concept at this point.

mcjazzyfunky commented 4 years ago

@mindplay-dk Thanks a lot for your answers. Actually I'm very interested in your ideas as your (quote) "little toy experiment" uses quite similar patterns like those I am using in some of my R&D projects.

See your example:

const Something = component(counter => ...)

If you rename what you've called counter to c and instead of passing the whole Preact.Component instance just pass a facade/controller object as follows:

const c = {
  props,
  useState,
  useContext
  useEffect
  useLayoutEffect,
  ...
}

then you basically have what I have called "c thingy" above in that what I've called "simple factory pattern".

Again, this isn't super hot, with custom hooks having a different signature from the built-in hook

The simplest solution to this is to declare that "c thingy" opaque (as done in ivi) and provide the following hook function out-of-the-box (the following is obviously a quick'n'dirty implementation):

export const useState = (c, ...args) => c.useState(...args)
export const useContext = (c, ...args) => c.useContext(...args)
export const useEffect = (c, ...args) => c.useEffect(...args)
export const useLayoutEffect= (c, ...args) => c.useLayoutEffect(...args)

Now you still have to decide how to handle the props. There are a bunch of solutions of course. I personally am not really sure whether it is really a good idea that hook functions have access to the current props. I have never seen one single example of a hook function where this is necessary (if there are good examples where hook functions need access to the current props - except for useProps - then please let me know).

For example in your demo, the following ...

const Counter = component(counter => {
  const [state, setState] = counter.useState(({ value }) => ({
    count: value || 0
  }));

  ....
}

... could easily be implemented as either ...

// `props` is a auto-mutating object here - always loaded with the current props,
// similar to Vue
const Counter = component((counter, props) => {
  const [state, setState] = counter.useState({ count: props.value || 0 });
  ....
}

... or ...

const Counter = component((counter, getProps) => {
  const [state, setState] = counter.useState({ count: getProps().value || 0 });
  ....
}

I personally prefer currently that the API of that c thingy does not reflect the type of hook functions I will use later but is more abstract (you could write hook functions 100% like ivi does on top of that or hook functions similar to the patterns in your POC project)

const Counter = component((c: Ctrl) => {
  const [state, setState] = useState(c, { count: 0 })
  ...
})

type Ctrl = {
  // currently no access to the current props
  update(runOnceBeforeRender?: () => void): void,
  isMounted(): boolean, // sugar
  getContextValue<T>(context: Context<T>): T, // I've cheated to make this possible
  afterMount(subscriber: Subscriber): void, // `unsubscribe` not possible by design
  beforeUpdate(subscriber: Subscriber): void,
  afterUpdate(subscriber: Subscriber): void,
  beforeUnmount(subscriber: Subscriber): void,
  // ...
}

//  without going into details: `runOnceBeforeRender` and `beforeUpdate` are 
// a bit odd ... do not like them too much, but currently have no other idea

type Props = Record<string, any>
type Subscriber = () => void
type Context<T> = Preact.Context<T>

There are three main things I really love about the React hook API:

  1. The possibility of writing hook functions, of course => this is obviously possible with the patterns that you and I are using above and also in a quite nice alternative way in ivi.

  2. Except for ref objects with react hooks all data is immutable (at least in most cases): props, count, state etc. I do not know a way how this can be achieved with that "simple factory pattern" (without using getters all the time of course, but I would not like that too much). This is really something I miss the most - this is sooooooo much better in React and ivi. I really really hope, those guys working on that "reactive" solution will show me some day a reason, why this after all will still make really sense (and the whole things is not just a stopgap as JavaScript does not have PHP-like references or C-like pointers).

  3. When you always define components the following way in React

function Counter ({
  initialCount = 0,
  title = 'Counter',
  onChange
}) {
  ....
}

you alway see directly what properties are supported by the component (even without TypeScript) and also you see the default props directly - both without even have to provide some special API for that. This is sooooo amazing - I cannot express how much I like that. I have seen something like this only with React-like hook API and ivi (but it's nicer in React). I am missing this completely in all other APIs I have seen (especially that "simple factory pattern" I am using recently in some of my R&D projects).

mindplay-dk commented 4 years ago

@mcjazzyfunky yeah, a factory/builder proxy of some sort being passed, rather than than the actual component instance, that would make a lot more sense - I mostly did it that way to get to a functioning prototype quickly without adding another degree of abstraction. I also like your idea of having individual hooks (before/after/once) rather than compounding these like hooks do.

With regards to naming, I use e.g. counter when I know I'm configuring the counter - if I write custom hooks, I use names like c or component, as those calls could be operating on different components.

I've never been fond of the use prefix - the low-level built-in hooks are very different in nature from high-level custom hooks; custom hooks are a different layer of abstraction, so I actually think it's natural for them to have different naming/calling conventions. I would much prefer names like afterMount, beforeUpdate as in your example for the built-in hooks - in my own POC I mostly kept the names because it requires less work to explain it to someone who's already smitten with React hooks. 😊

mcjazzyfunky commented 4 years ago

Hi again,

for those of you who are still interested in alternative component API's and by any change have not yet heard of Google's "Jetpack Compose" project (for Android) => https://developer.android.com/jetpack/compose (not production-ready yet):

It uses Kotlin as programming language and applies a special Kotlin compiler plugin. With this combo you have of course much more possibilities as in ECMAScript and so some of the downsides of React's API can be fixed there. If not already done, check it out ... it really interessting :)


And then some other remarks: I've again noodled around a bit with some API alternatives for React hooks and actually I came to the conclusion (at least for myself) that maybe that dogmatic view that syntactical conciseness is one of the major goals is not very expedient. If the syntax is fine and more verbose than its React counterpart but not tooooo verbose then maybe that's still okay. Also, I've never really been a big fan of using a useProps hook (especially to handle default props) - even if I have used it from time to time in some of my examples above => I've changed my mind here: Actually without going into details I think using a useProps hook is not a bad idea and using it to handle default props solves some subtle TS typing challenges (of course if you use ivi-like or react-like hooks in the first place you do not even have such problems nor need some usePropshook function or whatever - but if you use some other alternative hook API it may be useful). Also using this useProps function helps a bit to implement really all hook related stuff completely in userland.

Here's once again an additional example ... frankly I'm really fine with the syntax it is using (even if you may get used to it a bit): https://codesandbox.io/s/ecstatic-shape-p1uhx

What's important here is that the core of that toy library (module: "js-widgets") does currently only have six functions component, context, h, render, Fragment, Boundary (obviously portal, Suspense etc. are still missing, but that may not be very important here) and that this core API does not know anything about hooks and therefore does not dictate how stateful components have to be implemented. It's just expects a ivi-like init function the rest is up to you. It indeed provides a module "js-widgets/hooks" but if you prefer hooks the ivi-way or the React way and implement a corresponding module in userland the core of that little toy library will be completely fine with that.

zouloux commented 2 years ago

Hi all, long time no see :) After 4 years I took some time to create https://github.com/zouloux/reflex. It seems to be working and have some conceptual changes from the original idea. After some tests and benchmarks (that I need to continue), I got better performances than Preact (less CPU and less memory usage) and library is less than 4kb gzipped (Preact for ex is 3kb but without hooks). It has a lot of stuff missing from React, (on purpose) but i'll check what to include next (like Suspense or Async rendering) I got some ideas inspired from Solid, which I find very neat (like the Proxy Props) and which also kind of have factory pattern. This is certainly useless and a crazy idea to re-create a V-Dom lib, but it was a very good exercise to do ! Still in very-very-early beta, doc and demos are not finished yet. See ya ✌️

xdev1 commented 2 years ago

@zouloux You're such a badass :smiley:, implementing all that stuff in reflex - my deepest R.E.S.P.E.C.T! :+1:

Are you interested in some remarks (like always: It's totally fine to have a completely different opinion than I have :wink:)?

zouloux commented 2 years ago

Hi @xdev1 and thanks for your feedback ! Yep I check how Preact deals with hooks as a separated sub-module and I'm not convinced by the option thing. Something like getCtrl seems a good alternative ! I also want to try a React hooks style implementation in Reflex but that's will not be into the v1, it will definitely be in a future version, as a separated sub-module, if I continue to maintain the lib. I did't checked about the setDefaults for props but it may be like early React version with something like :

Component.defaultProps = {
    name: 'Unknown'
}
function Component ( props ) {
    return () => <div>Hello {props.name}</div>
}

I think it will be easier for types and auto-complete and I find it clean enough (on top of component, not callable several times like setDefaults).

xdev1 commented 2 years ago

I think it will be easier for types and auto-complete

Honestly, I doubt that. How will you make the following example work properly in TypeScript (without adding a "!" or whatever everywhere you'll meet those annoying type issues)? props.name might be undefinedso you cannot just call props.name.toUpperCase() and therefore this will not compile.

MyComponent.defaultProps = {
    name: 'Unknown'
};

function MyComponent(props: { name?: string }) {
    return () => <div>HELLO {props.name.toUpperCase()}</div>;
}

Neither in React nor in Solid (using mergeProps) nor in my demo above you'll have those kind of type problems.

Please have a look at my previous demo again (I've updated it a bit, to better show that type issue):

BTW: Using MyComponent.defaultProps = ... before function MyComponent(...) { ... } might result in an ESLint error (=> @typescript-eslint/no-use-before-define), depending on configuration of course. Just defining the defaultProps after the component function, will make the definitions of props and default props not be collocated any longer.

As a variant of my demo: You can of course define the counter default props right in front of the Counter function definition (const counterDefaults = { ... }) and use that with the setDefaults or preset functions, so that constant default props object will not be recreated unnecessarily again and again for each counter instance. But I personally do not like the DX that comes with that pattern, and so I personally prefer the following as an optional (!) syntax variation (this is not implemented in the demo above):

const props = preset(p, () => ({
  count: 0,
  label: 'Counter'
});

The function preset will make sure that, in case the second argument is a function, this function will only be called once when initializing the first Counter instance, and the result will be cached and reused for all other Counter instances. Some folks may call this "odd" or "too much magic" or "premature optimization" or whatever, but what do I care?! :wink:

xdev1 commented 2 years ago

Oh BTW, the most important question, IMHO: How are you planning to handle suspense in Reflex? If there's a nice syntactical way to handle suspense concerns in the component function, then great ... otherwise this may be a dealbreaker for the whole "factory-based component definitions" idea.

zouloux commented 2 years ago

Hey, you changed my mind, I think you are right about the props defaults ! I'll check how to implement it because props is a Proxy object. Now it's a different proxy by component, but for performances purpose, I'll provide the same Proxy instance to all components, and the proxy will dispatch props automatically. For suspense, I see no issue about implement it in Reflex. I need to propagate an env object through nodes like petit-dom does it. For now Reflex does not support SVG, and it's needed for SVG so it may come at the same time. It's not planned to use the same API, I personally does not like the <Spinner /> as prop, I need to check if something cleaner is possible. Also looking into implementing useTransition and useFetch as separate package !

zouloux commented 2 years ago

And thanks for your feedbacks 🙌 ! Feel free to create some PR if you want to be involved, dirty code is OK since code review.

xdev1 commented 2 years ago

[...] for performances purpose, I'll provide the same Proxy instance to all components, and the proxy will dispatch props automatically.

Before you waste time on that, I don't think that this will work. You will indeed need a separate props proxy for each component instance, because using always the same props proxy will fail in a lot of async cases. See for example this very realistic :wink: demo component. To access props.value properly there, you really need separate props proxies.

function Demo(props: { value: string }) {
  mounted(() => {
    const intervalId = setInterval(() => {
      console.log('Current value:', props.value);
    }, 1000);

    return () => clearInterval(intervalId);
  });

  return () => <div>Please check JS console...</div>;
}
zouloux commented 2 years ago

Are you a wizard ? Already lost some minutes on this but it was easy to recover ! Thanks ✌️

zouloux commented 2 years ago

New version with defaultProps, svg support, renderToString 🎉 https://github.com/zouloux/reflex

zouloux commented 2 years ago

Plus some performance improvements, it seems faster than Preact now with a bit less memory usage https://github.com/zouloux/reflex#performances

xdev1 commented 2 years ago

@zouloux May I ask you to provide a little demo in TypeScript where you use default props with Reflex?

zouloux commented 2 years ago

Yep :) It should be something like this :

interface IComponentProps {
    title ?: string
}
function Component ( props:IComponentProps, component:IComponentAPI<IComponentProps> ) {
    // Works on factory and functional components
    component.defaultProps = {
        title: "Default title",
        notExisting: true // Rises a TS error
    }
    console.log("title", props.title) // "Default title"
    return <h1>{ props.title }<h1>
}

For now defaultProps type needs to be given through component generics, but I hope to find something to automate this like JSX runtime types. IComponentAPI will have other utilities like imperative handles, component forwarded ref. Also shouldUpdate works with IComponentAPI -> component.shouldUpdate = (n, o) => n.title !== o.title

xdev1 commented 2 years ago

@zouloux Many thanks ... but again props.title.toUpperCase() will not work, as props.title may still be undefined.

zouloux commented 2 years ago

Sorry but I do not get how props.title could be undefined ? Can you tell me more ? I can try some more real-case examples quickly. Thanks a lot :)

xdev1 commented 2 years ago

Sorry but I do not get how props.title could be undefined ?

Sorry, my bad ... I've used the wrong wording. I meant: The type of props.title is still string | undefined and TypeScript will throw an compile-time error saying "Object is possibly 'undefined'". While in reality props.title will not be undefined any longer, as a default value has been defined for prop title, but TypeScript doesn't know that. I'll prepare a little demo ... stay tuned :wink:

xdev1 commented 2 years ago

@zouloux Here's the demo: https://codesandbox.io/s/default-props-39wquo?file=/src/index.tsx [Edit] And don't tell me the upper case should be done by CSS :smile:

zouloux commented 2 years ago

Aaaaaaaaaah the strict mode in tsconfig ... Yep that's an issue I did not think of ! I didn't catch you were talking about types and not values here, but I should take care of what you are saying because you are right again :) I'll search for a solution but the preset hook is maybe the only way to have this working in strict mode. Can't destruct in function arguments, can't use IComponentAPI to propagate types back to GProps generics, or if there is a way, I do not know it. Thanks again ✌️

xdev1 commented 2 years ago

@zouloux Here's a bunch of assorted notes/remarks/ideas (some of them highly opinionated)

xdev1 commented 2 years ago

(... continuing list from latest comment ...)


I'll search for a solution but the preset hook is maybe the only way to have this working in strict mode.

The only type-safe solutions that I know are the following:

// React version for comparison
// lines: 18, chars: 341

type CounterProps = {
  name: string;
  initialCount?: number;
};

function Counter({
  name,
  initialCount = 0
}: CounterProps) {
  const [count, setCount] = useState(initialCount);
  const increment = () => setCount(it => it + 1);

  return (
    <button onClick={increment}>
      {name}: {count}
    </button>  
  )
}
// This the shortest version of my proposals above:
// 17 lines, 342 chars

function Counter(p: {
  name: string;
  initialCount?: number;
})
  preset(p, () => ({
    initialCount: 0
  }));

  const [getCount, setCount] = stateVal(p.initialCount);
  const increment = () => setCount(it => it + 1);

  return () => (
    <button onClick={increment}>
      {p.name}: {getCount()}
    </button>
  );  
});
/*
  This curried `component` function may not have
  a very popular syntax, but is actually surprisingly
  concise and can be implemented on top of most
  modern vdom-based component libraries.
  In the following form the component does not
  have a "display name", if needed for debugging etc.
  it has to be added automatically in the build process
  or declared explicitly:

     const Counter = component('Counter')<{
        ... 
     });
*/
// 15 lines, 329 chars

const Counter = component<{
  name: string;
  initialCount?: number;
}>({
  initialCount: 0
})((p) => {
  const [getCount, setCount] = stateVal(p.initialCount);
  const increment = () => setCount(it => it + 1);

  return () => (
    <button onClick={increment}>
      {p.name}: {getCount()}
    </button>
  );  
});

[Edit]: Here's an additional alternative API based on a lot of function compositions (may look a bit unusual first, but is quite flexible)

const Counter = component('Counter')(
  props<{
    name: string;
    initialCount?: number;
  }>,

  defaults({
    initialCount: 0
  })
)((p) => {
  const [s, set] = state({ count: p.initialCount });
  const increment = () => set.count(it => it +1);

  return () => (
    <button onClick={increment}>
      {p.name}: {s.count}
    </button>
  );
});

Of course in this simple example just using p.initialCount ?? 0 would have also worked, but the purpose was to show general "default props" patterns.