preactjs / signals

Manage state with style in every framework
https://preactjs.com/blog/introducing-signals/
MIT License
3.72k stars 91 forks source link

Computed signal with async callback #284

Open HappyStriker opened 1 year ago

HappyStriker commented 1 year ago

Hey there,

I am using Preact more and more since the publication of the Signals API and its a great and, especially in relation to regular state and context, easy way to build a reactive user Interface.

In my newest project though I have reached an issue for which I am looking for a recommended way to solve it: Whats the best way to handle a computed signal when its callback method is asynchronous but you want to deal only with its resolved value?

Example:

const address = signal('localhost');
const content = computed(async () => {
  // loads something like `http://${address.value}/` and fully handles errors so the async function will never throw
});

The code above will result in content always being a Promise, but I want it to be the resolved value of the async function instead.

So far I have solved this issue with something like this:

const address = signal('localhost');
const content = signal(null);
effect(async () => {
  // loads something like `http://${address.value}/` and fully handles errors so the async function will never throw
});

That solution does work, but seems a bit messy, especially because it can not be used directly in a class.

Is there a better solution to handle this or would it even be a reason for adding a new method to Signals like asyncComputed() (or computed.await())?

Thank you very much for your time and I am looking forward to an interessting discussion.

Kind regards, Happy Striker

HappyStriker commented 1 year ago

In the meantime I have played with an additional method that looks like this:

computed.await = function(v, cb = arguments.length === 1 ? v : cb) {
  const s = signal(arguments.length === 2 ? v : undefined);
  effect(() => cb().then((v) => s.value = v));
  return computed(() => s.value);
};

where v is the optional initial value to be used until the async function does return.

This extension can eg. be used for loading a page using a promise in a oneliner like below:

const page = computed.await('Loading...', async () => (await fetch('app.html')).text());
render(HTML`<div>${page}</div>`);

In something like that possible with Signals at the current moment or maybe worth being added? In case an addition is not aimed for, is there any feedback if the method above is save to use or is it messing to much with some internals that I am not aware of?

Thanks for your feedback.

loicnestler commented 1 year ago

bump

JoviDeCroock commented 1 year ago

I have an RFC open https://github.com/preactjs/signals/issues/291 and a POC implementation https://github.com/preactjs/signals/compare/main...async-resource πŸ˜…

tylermercer commented 1 year ago

@JoviDeCroock do you know if any further work has been done on this? It looks like you closed your RFC?

akbr commented 9 months ago

Some kind of standard solution here would be awesome. πŸ˜‰

JoviDeCroock commented 9 months ago

Folks testing out the branch I created could go a pretty long way in getting this out there πŸ˜… I don't personally have good testing grounds at the current time

XantreDev commented 9 months ago

I've implemented a resource and tested it: https://www.npmjs.com/package/@preact-signals/utils

MicahZoltu commented 8 months ago

The problem with any sort of async stuff with Signals right now is that when you do mySignal.value it calls addDependency(this) internally. This function checks to see if it is executing inside of a context that is tracking signal references (e.g., inside a useComputed) and if so, it adds mySignal as a dependency to that context. This is how useComputed is able to automatically update anytime any used signals are touched.

The problem is that as soon as you do await inside of an async function, you leave the context that is tracking changes and any references to other signals inside the async function won't be tracked, because they occur inside a continuation which is in a different context.

I don't know how to address this problem, even with significant changes to how signal tracking is done. Users can deal with this by making sure to reference any signals they care about before the first await, but that is a really easy foot-gun.

You can see the problem illustrated here:

import { computed, signal } from '@preact/signals-core'
import * as process from 'node:process'

async function sleep(milliseconds: number) { await new Promise(resolve => setTimeout(resolve, milliseconds)) }

async function main() {
    const apple = signal(3n)
    const banana = signal(5n)

    // create a computed signal that is derived from the contents of both of the above signals
    const cherry = computed(async () => {
        // apple is "read" before the await
        apple.value
        // sleep for a negligible amount of time to ensure the rest of this function runs in a continuation
        await sleep(1)
        // banana is "read" after the await
        banana.value
        // final computed value
        return apple.value + banana.value
    })

    console.log(`Apple: ${apple.value}; Banana: ${banana.value}; Cherry: ${await cherry.value}`)
    // Apple: 3; Banana: 5; Cherry: 8

    // change the value of apple and notice that Cherry correctly tracks that change
    apple.value = 4n
    console.log(`Apple: ${apple.value}; Banana: ${banana.value}; Cherry: ${await cherry.value}`)
    // Apple: 4; Banana: 5; Cherry: 9

    // change the value of banana, and notice that Cherry is **not** recomputed (should be 10)
    banana.value = 6n
    console.log(`Apple: ${apple.value}; Banana: ${banana.value}; Cherry: ${await cherry.value}`)
    // Apple: 4; Banana: 6; Cherry: 9

    // change the value of apple again, and notice that Cherry is correctly recomputed using the value of banana set above
    apple.value = 11n
    console.log(`Apple: ${apple.value}; Banana: ${banana.value}; Cherry: ${await cherry.value}`)
    // Apple: 11; Banana: 6; Cherry: 17
}

main().then(() => process.exit(0)).catch(error => { console.error(error); process.exit(1) })
XantreDev commented 8 months ago

@MicahZoltu There is no reactivity system that handles this case, because of design of js. Main solution is to use resource like api and pass all deps explicitly: https://docs.solidjs.com/references/api-reference/basic-reactivity/createResource

I've implemented analog for preact-signals: https://github.com/XantreGodlike/preact-signals/tree/main/packages/utils#resource

Another solution is to not use async functions but generator functions:

import { Effect } from "effect"

const increment = (x: number) => x + 1

const divide = (a: number, b: number): Effect.Effect<never, Error, number> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b)

// $ExpectType Effect<never, never, number>
const task1 = Effect.promise(() => Promise.resolve(10))
// $ExpectType Effect<never, never, number>
const task2 = Effect.promise(() => Promise.resolve(2))

// $ExpectType Effect<never, Error, string>
export const program = Effect.gen(function* (_) {
  const a = yield* _(task1)
  const b = yield* _(task2)
  const n1 = yield* _(divide(a, b))
  const n2 = increment(n1)
  return `Result is: ${n2}`
})

Effect.runPromise(program).then(console.log) // Output: "Result is: 6"

https://www.effect.website/docs/essentials/using-generators#understanding-effectgen

MicahZoltu commented 8 months ago

The pattern followed by createResource is the same one as I currently follow for async state. See https://github.com/Zoltu/preact-es2015-template/blob/master/app/ts/library/preact-utilities.ts#L18-L62 for my rendition of it. It works well, but not for "computed" values.

I don't see how generators would help solve the problem where we want to re-run some async work when a signal that is read after the async work is touched.

const mySignal = signal(5)
useComputed(async () => {
    // mySignal.value
    const result = await fetch(...)
    const fetchedValue = getValueFromResult(result)
    return fetchedValue + mySignal.value
}

In this situation, we want to re-fetch the value when mySignal changes, but that won't happen unless we uncomment the first line of the computed function. While this works, it is a really easy mistake to make to forget to read all of your signals before the first await, just like it is easy to forget to pass your dependencies to things like useEffect.

XantreDev commented 8 months ago

You need to write your own wrapper around generators which will start tracking after some promise resolves

XantreDev commented 8 months ago

Write it with resource in this way

const [resource, { refetch }] = createResource({
  source: () => ({ mySignal: mySignal.value }),
  fetcher: async ({ mySignal }) => {
    const result = await fetch(...)
    const fetchedValue = getValueFromResult(result)
    return fetchedValue + mySignal
  },
});
MicahZoltu commented 8 months ago

This doesn't seem much better than just doing this (which works):

const mySignal = signal(5)
useComputed(async () => {
    mySignal.value
    const result = await fetch(...)
    const fetchedValue = getValueFromResult(result)
    return fetchedValue + mySignal.value
}

Having the source field required helps a little bit, as it reminds you that you need to do something, but if you reference multiple signals it is very easy to forget one of them in either case.

XantreDev commented 8 months ago

I don't think this case should be handled. You have primitives for this case, if you want - you can write your own wrapper around generators, but I don't think it will be useful or beautiful Maybe good solution will be eslint plugin that checks it, but it will have false positives with some object with value field. I actually don't think you should use async functions with computed at all, there are resource for that cases. Signals is sync primitive, if you want async reactivity - you should use rxjs

oravecz commented 8 months ago

Sorry to interject something I have just been exposed to, and I can’t say whether it would help this particular use case, but it does handle generators and promises in a way I have yet to see.

Β https://effect.website/ On Dec 29, 2023 at 2:34 PM -0500, Valerii Smirnov @.***>, wrote:

I don't think this case should be handled. You have primitives for this case, if you want - you can write your own wrapper around generators, but I don't think it will be useful or beautiful Maybe good solution will be eslint plugin that checks it, but it will have false positives with some object with value field. I actually don't think you should use async functions with computed at all, there are resource for that cases. Signals is sync primitive, if you want async reactivity - you should use rxjs β€” Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you are subscribed to this thread.Message ID: @.***>

aadamsx commented 8 months ago

I'm starting a new React.js project, and was looking at replacing react's useEffect, etc. with Singals. But it seems when l populate the signal with the result of an async fetch statement and try to use the resulting signal to populate my component, nothing ever renders (unless I do a console log of the data for some strange reason). I look all over the place and have never seen an example of an async fetch call used to populate a signal. We do this all the time in React.js with useEffect. Does anyone know if this is possible with signals? If not then I guess I'll stick with react hooks.

XantreDev commented 8 months ago

I'm starting a new React.js project, and was looking at replacing react's useEffect, etc. with Singals. But it seems when l populate the signal with the result of an async fetch statement and try to use the resulting signal to populate my component, nothing ever renders (unless I do a console log of the data for some strange reason). I look all over the place and have never seen an example of an async fetch call used to populate a signal. We do this all the time in React.js with useEffect. Does anyone know if this is possible with signals? If not then I guess I'll stick with react hooks.

I've implemented two way to manage async state with signals. Resource

const A = () => {
  const [resource, { refetch }] = useResource(() => ({
    fetcher: async () => {
      const response = await fetch("https://example.com");
      return response.json();
    },
  }));

  return <div>{resource()}</div>
}

Also there are full featured port of @tanstack/react-query with signals tracking, btw you can use original react query.

aadamsx commented 8 months ago

Can I use

import { useResource } from "@preact-signals/utils/hooks";  // this import

...
const App = () => {
  const [layoutResource, { refetch }] = useResource<SectionData[]>({
    fetcher: async () => {
      const response = await fetch("/api/layout");
      if (!response.ok) {
        throw new Error("Network response was not ok");
      }
      const data = await response.json();
      updateHeaderLayoutData(data);
      return data;
    },
  });
...

with just preact and NOT react.js? If it works with preact too, what do I need to install?

This doc: https://github.com/XantreGodlike/preact-signals/tree/main/packages/utils

This part:

Ensure that one of the preact signals runtimes is installed:

@preact/signals for preact, requiring an additional step. @preact/signals-core for vanilla js requiring an additional step. @preact-signals/safe-react for react, requiring an additional step. @preact/signals-react for react.

Makes it sound like I only need one of these packages installed for this to work with preact (I'm using Vite for build), in my case since I'm using only preact @preact/signals, but when I install:

npm i @preact-signals/utils

it creates a dependency on @preact/signals-react that can be seen in the package-log.json file.

EDIT:

I see, I added:

alias: [
  { find: "react", replacement: "preact/compat" },
  { find: "react-dom/test-utils", replacement: "preact/test-utils" },
  { find: "react-dom", replacement: "preact/compat" },
  { find: "react/jsx-runtime", replacement: "preact/jsx-runtime" },
  { find: "@preact/signals-react", replacement: "@preact/signals" },
],

},

to my vite.config.ts and it seems to be working. Is this the recommendation?

XantreDev commented 8 months ago

Can I use

import { useResource } from "@preact-signals/utils/hooks";  // this import

...
const App = () => {
  const [layoutResource, { refetch }] = useResource<SectionData[]>({
    fetcher: async () => {
      const response = await fetch("/api/layout");
      if (!response.ok) {
        throw new Error("Network response was not ok");
      }
      const data = await response.json();
      updateHeaderLayoutData(data);
      return data;
    },
  });
...

with just preact and NOT react.js? If it works with preact too, what do I need to install?

This doc: https://github.com/XantreGodlike/preact-signals/tree/main/packages/utils

This part:

Ensure that one of the preact signals runtimes is installed:

@preact/signals for preact, requiring an additional step. @preact/signals-core for vanilla js requiring an additional step. @preact-signals/safe-react for react, requiring an additional step. @preact/signals-react for react.

Makes it sound like I only need one of these packages installed for this to work with preact (I'm using Vite for build), in my case since I'm using only preact @preact/signals, but when I install:

npm i @preact-signals/utils

it creates a dependency on @preact/signals-react that can be seen in the package-log.json file.

EDIT:

I see, I added:

alias: [
  { find: "react", replacement: "preact/compat" },
  { find: "react-dom/test-utils", replacement: "preact/test-utils" },
  { find: "react-dom", replacement: "preact/compat" },
  { find: "react/jsx-runtime", replacement: "preact/jsx-runtime" },
  { find: "@preact/signals-react", replacement: "@preact/signals" },
],

},

to my vite.config.ts and it seems to be working. Is this the recommendation?

Yes, it's recommended way to use it with preact

oravecz commented 8 months ago

Is there a version of useResource that works outside a component?

XantreDev commented 8 months ago

Ofc. There are createResource function

XantreDev commented 8 months ago

https://tsdocs.dev/docs/@preact-signals/utils/functions/createResource.html

aadamsx commented 8 months ago

@XantreGodlike thanks for your feedback.

I assign the JSON from the async call to a signal. I make another async call to the same URL, it returns the JSON, and again, nothing has changed in the data structure (no values are different), and I again assign it to the same signal. I am passing the values of this signal to child components. Will the child components rerender? What I'm looking for is to only update compoents where the data has changed.

For example:

\\ app.tsx
import { effect } from "@preact/signals";
import { useResource } from "@preact-signals/utils/hooks";
import type { SectionData } from "./types";
import {
  updateHeaderLayoutData,
  h1LinkStore,
  h2LinkStore,
  h3LinkStore,
} from "./signals/store";
import Header from "./components/header/Header";

const App = () => {
  const [layoutResource, { refetch }] = useResource<SectionData[]>({
    fetcher: async () => {
      console.log("Fetching data");
      const response = await fetch("/api/layout");
      if (!response.ok) {
        throw new Error("Network response was not ok");
      }
      const data = await response.json();
      updateHeaderLayoutData(data);
      return data;
    },
  });

  // effect(() => {
  //   const interval = setInterval(() => {
  //     refetch();
  //   }, 60000); // 60 seconds
  //   console.log("Calling refetch");
  //   return () => clearInterval(interval); // Cleanup on unmount or signal change
  // });

  return (
    <div className="min-h-screen flex flex-col">
      <Header
        h1Links={h1LinkStore.value || []}
        h2Links={h2LinkStore.value || []}
        h3Links={h3LinkStore.value || []}
      />
    </div>
  );
};

export default App;

Then the signal:

\\ store.ts

import { signal } from "@preact/signals";
import type { SectionData, Link } from "../types";

export const headerLayoutStore = signal<SectionData[]>([]);
export const h1LinkStore = signal<Link[]>([]);
export const h2LinkStore = signal<Link[]>([]);
export const h3LinkStore = signal<Link[]>([]);

export const updateHeaderLayoutData = (newData: SectionData[]) => {
  const filteredData = newData.filter(data => 
    data.section.startsWith("H1") || 
    data.section.startsWith("H2") || 
    data.section.startsWith("H3")
  );
  headerLayoutStore.value = filteredData;

  h1LinkStore.value = filteredData
    .filter(data => data.section.startsWith("H1"))
    .flatMap(data => data.placements.flatMap(p => p.links));
  h2LinkStore.value = filteredData
    .filter(data => data.section.startsWith("H2"))
    .flatMap(data => data.placements.flatMap(p => p.links));
  h3LinkStore.value = filteredData
    .filter(data => data.section.startsWith("H3"))
    .flatMap(data => data.placements.flatMap(p => p.links));
};

Then the header:

\\ header.tsx
import DefaultLink from "../common/DefaultLink";
import { Link } from "../../types";

type HeaderProps = {
  h1Links: Link[];
  h2Links: Link[];
  h3Links: Link[];
};
let renderCount = 0;

const Header = ({ h1Links, h2Links, h3Links }: HeaderProps) => {
  // Increment and log the render count
  console.log(`Header render count: ${++renderCount}`);
  return (
    <header className="p-4 text-center">
      <ul className="list-none text-left">
        {h1Links.map((link, index) => (
          <DefaultLink
            key={index}
            URL={link.URL}
            ImageURL={link.ImageURL}
            Description={link.Description}
            TextColorClass={link.TextColorClass}
          />
        ))}
      </ul>
    </header>
  );
};

export default Header;

and finally the last child component:

\\ defaultlink.tsx
import { Link as LinkType } from "../../types";

let renderCount = 0;

const DefaultLink: React.FC<LinkType> = ({
  URL,
  ImageURL,
  Description,
  TextColorClass = "text-black",
}) => {
  // Increment and log the render count
  console.log(`DefaultLink render count: ${++renderCount}`);

  return (
    <>
      {ImageURL && (
        <img
          src={ImageURL}
          alt={Description}
          className="max-w-full h-auto mb-2 pl-2 pr-2 pt-4"
        />
      )}
      <a
        href={URL}
        target="_blank"
        rel="noopener noreferrer"
        className={`block p-2 underline custom-font hover:bg-gray-100 ${TextColorClass}`}
      >
        {Description}
      </a>
    </>
  );
};

export default DefaultLink;

if the data doesn't change for the H1-H3 portions of the JSON in the async call in app.tsx, I don't want the defaultlin.tsx component to rerender.

Do I need a "computed" to check the value of the new state against the old one and set a boolean as a rerender check perhaps?

XantreDev commented 8 months ago

@aadamsx You should pass signals to children components to prevent child components rerenders. Also it's better to use resource like this:

const filterSections = (
  sections: SectionData[],
  startsWith: `H${1 | 2 | 3}`[]
) =>
  sections.filter((it) =>
    startsWith.some((start) => it.section.startsWith(start))
  );
const [resource] = createResource({
  fetcher: async () => {
    console.log("Fetching data");
    const response = await fetch("/api/layout");
    if (!response.ok) {
      throw new Error("Network response was not ok");
    }
    const newData = (await response.json()) as SectionData[];

    const filteredData = filterSections(newData, ["H1", "H2", "H3"]);

    return {
      h1: filterSections(filteredData, ["H1"]).flatMap((data) =>
        data.placements.flatMap((p) => p.links)
      ),
      h2: filterSections(filteredData, ["H2"]).flatMap((data) =>
        data.placements.flatMap((p) => p.links)
      ),
      h3: filterSections(filteredData, ["H3"]).flatMap((data) =>
        data.placements.flatMap((p) => p.links)
      ),
      filtered: filteredData,
    };
  },
});

resource.latest.h1 // some data if fetched
// if you prefer to wrap it in signal
const h1Sig = computed(() => resource.latest.h1)
AfaqaL commented 7 months ago

@XantreGodlike is it possible to pass fetcher arguments from the outside? for example an access token so the fetch can bypass the authorization?

XantreDev commented 7 months ago

@AfaqaL You can pass arguments from source and with refetch method. But for authorization it's better to use wrapper around fetch with this functionality

AfaqaL commented 7 months ago

@XantreGodlike I don't know if I'm understanding this correctly but this is how I managed to pass the data through

const sigAuthToken = signal("");

const [res] = createResource({
    source: () => sigAuthToken.value,
    fetcher: async (token) => {
        if (token === "") return {authed: false}

        const infoResp = await fetch(`${process.env.REACT_APP_HOST}/auth/user-info`,
            {
                method: "GET",
                headers: {
                    "Content-Type": "application/json",
                    "Authorization": `Bearer ${token}`,
                }
            }
        )

        const {data} = await infoResp.json();
        return {...data, authed: true}
    }
}) 

when I log the data everything seems to be fetched as expected, however when I use the resource in my JSX components don't budge whatsoever (I tried to wrap in in computed() as well but no luck). Am I missing something to make JSX responsive to the resource ?

XantreDev commented 7 months ago

@AfaqaL i think it's ok implementation if you can have no token in fetcher. But in most places of the app use can be sure is user authorized or not. So you can just pass read sigAuthToken.value in fetcher.

If you're trying to create resource inside of component - you should use useResource to preserve resource on every render

AfaqaL commented 7 months ago

@XantreGodlike I'm using this resource in a lot of places in different components, thats why I want it to be created outside of the component, however the issue is that it doesn't cause a re-render whatsoever, maybe I'm using its value incorrectly? should I be accessing the .latest property directly inside a component for it to cause re-renders?

XantreDev commented 7 months ago

It's ok to create resource with createResource outside of components Do you use preact signals babel plugin? Can you send repro in stackblitz?