Skip to content

Proposal: deepSignal listenable container #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from

Conversation

EthanStandel
Copy link

@EthanStandel EthanStandel commented Sep 12, 2022

This is intended to be a solution to #4 . This proposal is generally just the meat of a library I made preact-signal-store, but I'd love to entirely deprecate this library and have this new primitive added to this package. I'd be certainly willing to add more tests or add to the demos website if the maintainers think that this primitive is worth having.

I also would be totally receptive of reccomendations for better sorting of the types as I realize the existence of DeepSignal, IDeepSignal and DeepSignalImpl appears obtuse/excessive.

I also considerred making the value getter on DeepSignal return a stored private computed _value property but honestly, I just didn't see what would be gained by this.

Here's the current README.md to the package for a better description of what this offers.

How it works

Very simply, the library just takes an arbitrary object of any scale or shape, and recursively turns all properties into signals. The objects themselves each turn into a DeepSignal which contains a value getter & setter as well as a peek method just like regular Signal instances. However, if you assign a new value to a DeepSignal, the setter method will recursively find every true Signal inside the object and assign them to a new value. So if you subscribe to a Signal in a component, you can guarantee that it will be updated no matter what level of the store gets reassigned.

So a simple example like this

import { deepSignal } from "preact-signal-store";

const userStore = deepSignal({
  name: {
    first: "Thor",
    last: "Odinson"
  },
  email: "[email protected]"
});

...is equivalent to this code...

const userStore = {
  name: {
    first: signal("Thor"),
    last: signal("Odinson"),
    get value(): { first: string, last: string } {
      return {
        first: this.first.value,
        last: this.last.value
      }
    },
    set value(payload: { first: string, last: string }) {
      batch(() => {
        this.first.value = payload.first;
        this.last.value = payload.last;
      });
    },
    peek(): { first: string, last: string } {
      return {
        first: this.first.peek(),
        last: this.last.peek()
      }
    },
  },
  email: signal("[email protected]"),
  get value(): { name: { first: string, last: string }, email: string } {
    return {
      name: {
        first: this.name.first.value,
        last: this.name.last.value
      },
      email: this.email.value
    }
  },
  set value(payload: { name: { first: string, last: string }, email: string }) {
    batch(() => {
      this.name.first.value = payload.name.first;
      this.name.last.value = payload.name.last;
      this.email.value = payload.email;
    });
  },
  peek(): { name: { first: string, last: string }, email: string } {
    return {
      name: {
        first: this.name.first.peek(),
        last: this.name.last.peek()
      },
      email: this.email.peek()
    }
  },
};

Using DeepSignal in a local context

By utilizing useDeepSignal you can get a local state DX that's very similar to class components while continuing to have the
performance advantages of signals.

import { useDeepSignal } from "preact-signal-store";

const UserRegistrationForm = () => {
  const user = useDeepSignal(() => ({
    name: {
      first: "",
      last: ""
    },
    email: ""
  }));

  const submitRegistration = (event) => {
    event.preventDefault();
    fetch(
      "/register",
      { method: "POST", body: JSON.stringify(user.peek()) }
    );
  }

  return (
    <form onSubmit={submitRegistration}>
      <label>
        First name
        <input value={user.name.first}
          onInput={e => user.name.first.value = e.currentTarget.value} />
      </label>
      <label>
        Last name
        <input value={user.name.last}
          onInput={e => user.name.last.value = e.currentTarget.value} />
      </label>
      <label>
        Email
        <input value={user.email}
          onInput={e => user.email.value = e.currentTarget.value} />
      </label>
      <button>Submit</button>
    </form>
  );
}

Recipes

Zustand style method actions

When I look to Zustand for the API it provides, it seems like a lot of their API (as much as I admire it) is based around supporting the
functional context. But the output of preact-signal-store is very openly dynamic and writing to it inside or outside of a component
ends up being the same. So you can take Zustand's basic example...

import create from "zustand";

export const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

...and create an effectively equivalent version with preact-signal-store like this...

import { deepSignal } from "preact-signal-store";

export const bearStore = {
  data: deepSignal({
    bears: 0
  }),
  increasePopulation() {
    this.data.bears.value++;
  },
  removeAllBears() {
    this.data.bears.value = 0
  }
};

Storing and fetching from localStorage

Because the value getter on a DeepSignal effectively calls the value getter on each underlying Signal, calling the DeepSignal's
getter will properly subscribe to each underlying signal. So if you wanted to manage the side effects of any level of a DeepSignal you
just need to call effect from @preact/signals and call DeepSignal.value

import { deepSignal } from "preact-signal-store";
import { effect } from "@preact/signals";

type UserStore = {
  name: {
    first: string;
    last: string;
  };
  email: string;
}

const getInitialUserStore = (): UserStore => {
  const storedUserStore = localStorage.getItem("USER_STORE_KEY");
  if (storedUserStore) {
    // you should probably validate this 🤷‍♂️
    return JSON.parse(storedUserStore);
  } else {
    return {
      name: {
        first: "",
        last: ""
      },
      email: ""
    };
  }
}

const userStore = deepSignal(getInitialUserStore());

effect(() => localStorage.setItem("USER_STORE_KEY", JSON.stringify(userStore.value)));

This would also work for any level of the DeepSignal.

import { deepSignal } from "preact-signal-store";
import { effect } from "@preact/signals";

type UserNameStore = {
  first: string;
  last: string;
};

const getInitialUserNameStore = (): UserNameStore => {
  const storedUserStore = localStorage.getItem("USER_NAME_STORE_KEY");

  // you should probably validate this too 🤷‍♂️
  return storedUserStore ? JSON.parse(storedUserStore) : { first: "", last: "" },
}

const userStore = deepSignal({
  name: getInitialUserNameStore(),
  email: ""
});

effect(() => localStorage.setItem("USER_NAME_STORE_KEY", JSON.stringify(userStore.name.value)));

This should fulfill most needs for middleware or plugins. If this fails to meet your needs, please file an
issue and I will address the particular ask.

TypeScript support

The API for deepStore and useDeepStore will handle dynamic typing for arbitrary input! It will also help you avoid a case like this

import { deepSignal } from "preact-signal-store";

const userStore = deepSignal({
  name: {
    first: "Thor",
    last: "Odinson"
  },
  email: "[email protected]"
});

// TS error: Cannot assign to 'email' because it is a read-only property.
userStore.value.email = "[email protected]"

@changeset-bot
Copy link

changeset-bot bot commented Sep 12, 2022

⚠️ No Changeset found

Latest commit: 5d0c6bc

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@netlify
Copy link

netlify bot commented Sep 12, 2022

Deploy Preview for preact-signals-demo ready!

Name Link
🔨 Latest commit 5d0c6bc
🔍 Latest deploy log https://app.netlify.com/sites/preact-signals-demo/deploys/631e8dd0716b6d0008e43fbc
😎 Deploy Preview https://deploy-preview-126--preact-signals-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

@EthanStandel EthanStandel changed the title feat: deepSignal listenable container Proposal: deepSignal listenable container Sep 12, 2022
@marvinhagemeister
Copy link
Member

Hey @EthanStandel this looks great! Thanks for filing a PR 👍

Admittedly we currently don't have the headspace for that as all our current focus goes into ironing out the remaining edge case bugs in core, making it as fast as we can and improving our adapters. This will probably take a bit of time, during which we'd like to hold off of doing API changes or additions. If you don't mind I think I'd prefer if it stays an addon for a little longer and we'll revisit this topic again when the time comes.

@EthanStandel
Copy link
Author

@marvinhagemeister that makes sense! I'll close this PR for now and watch as this repo matures! In the mean time I'll try to make some improvements on preact-signal-store so that if the time comes for it, it becomes a cleaner candidate

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants