lostpebble / pullstate

Simple state stores using immer and React hooks - re-use parts of your state by pulling it anywhere you like!
https://lostpebble.github.io/pullstate
MIT License
1.08k stars 23 forks source link

[Question] How to do async actions the Pullstate Way™ #26

Open Antonio-Laguna opened 4 years ago

Antonio-Laguna commented 4 years ago

Hi there!

I've been looking for some time for a decent alternative and less convoluted than Redux and I was ecstatic when I saw your module. Looks really nice!

I went through the docs and tried implementing it but felt a tad odd. I think my case is really straightforward and realized some people could run into this same pitfall or wonder so it could do for good examples that could enhance the docs/code.

Here's an "entity" which is a "page":

import { createAsyncAction, errorResult, successResult } from 'pullstate';
import ItemsStore from '../stores/items-store';

export function getPages() {
  return fetch(`${process.env.SERVER}/api/pages/pages?token=${process.env.TOKEN}`)
    .then(response => response.json())
    .then(json => json.map((name, index) => ({ index, name })));
}

export default createAsyncAction(
  async () => {
    const result = await getPages();

    if (result.length > 0) {
      return successResult(result);
    }

    return errorResult([], `Couldn't get pages: ${result.errorMessage}`);
  },
  {
    postActionHook: ({ result, stores }) => {
      if (!result.error) {
        ItemsStore.update(s => {
          s.pages = result.payload;
        });
      }
    },
    cacheBreakHook: ({ result, timeCached }) => {
      const CACHE_TIME = 60 * 60 * 1000; // 1 hour in milliseconds
      return timeCached + CACHE_TIME < Date.now();
    }
  },
);

Then on the view, I want to render this list of pages:

export function HomePage() {
  const [finished, result] = usePages.useBeckon();

  if (!finished) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  if (result.error) {
    return <div>{result.message}</div>;
  }

  return (
    <div>
      <ul>
        {result.payload.map(page => (<li key={page.index}>{page}</li>))}
      </ul>
    </div>
  );
}

However, it feels a bit convoluted and the result.payload feels kind of dirty to me which is what got me thinking if we were doing it right.

lostpebble commented 4 years ago

Hi there! :)

You're right, there could definitely be more examples about use cases - especially with the Async Actions. I've been meaning to make a short and quick video series about it too.

So basically, what it seems you're doing is making use of the both postActionHook and making use of the result.payload - where usually that is the choice that you need to make, which ever one suites the situation that you are in.

If you make use of postActionHook you should just be getting your pages from your ItemsStore using useStoreState in your component.

You also still use useBeckon to trigger and watch the execution state of your action in your UI, as you've done.

So what you could do is:

export function HomePage() {
  const pages = ItemsStore.useStoreState(s => s.pages);
  const [finished, result] = usePages.useBeckon();

  if (!finished) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  if (result.error) {
    return <div>{result.message}</div>;
  }

  return (
    <div>
      <ul>
        {pages.map(page => (<li key={page.index}>{page}</li>))}
      </ul>
    </div>
  );
}

The other option would be to not make use of postActionHook and just make use of the result directly, as you've done. I know it feels dirty, but its the only way to encapsulate the full state of a running async action. You could make it look slightly less messy like so:

export function HomePage() {
  const [finished, result] = usePages.useBeckon();

  if (!finished) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  if (result.error) {
    return <div>{result.message}</div>;
  }

  // Typescript guarentees that this is now definitely the
  // non-undefined payload since "result.error" = false
  const pages = result.payload;

  return (
    <div>
      <ul>
        {pages.map(page => (<li key={page.index}>{page}</li>))}
      </ul>
    </div>
  );
}

Or, of course pull out the payload earlier (cleanest looking but you lose some Typescript goodness):

  const [finished, { error, message, payload: pages }] = usePages.useBeckon();

  if (!finished) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  if (error) {
    return <div>{message}</div>;
  }

  // Typescript unfortunately isn't smart enough to infer that "pages" is
  // non-undefined here because we separated it out earlier

  return (
    <div>
      <ul>
        {pages.map(page => (<li key={page.index}>{page}</li>))}
      </ul>
    </div>
  );

So, you were actually already kinda doing things the Pullstate™ way :)

I know it feels a little verbose, but it definitely beats keeping track of all that state for each action. If you want you could also give the newer React Suspense stuff a go which should feel less verbose and more declarative - (https://lostpebble.github.io/pullstate/docs/async-action-use#react-suspense-read-an-async-action)

Antonio-Laguna commented 4 years ago

Wow, thanks for the insights! I think that's useful!