Skip to content
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

Add createLens primitive #452

Draft
wants to merge 46 commits into
base: main
Choose a base branch
from

Conversation

nathanbabcock
Copy link

@nathanbabcock nathanbabcock commented May 28, 2023

Utilities for working with nested reactivity in a modular way.

  • createLens - Given a path within a Store object, return a derived or "focused"
    getter and setter pair.

  • createFocusedGetter - The first half of the lens tuple; a derived signal
    using path syntax on an object.

  • createFocusedSetter - The second half of the lens tuple; a Setter
    for a specific path within a Store.

How to use it

// Start with an ordinary SolidJS Store
const storeTuple = createStore([
  { myString: 'first' }
])

// Create a lens to focus on one particular item in the Store.
// Any valid path accepted by `setStore` works here!
const [firstString, setFirstString] = createLens(storeTuple, 0, myString)

// Setters and Getters work just like ordinary Signals
setFirstString("woohoo") // equivalent to `setStore(0, "myString", "woohoo")
console.log(firstString()) // "woohoo"

Motivation

1. Separation of Concerns

Components can receive scoped Setters for only the parts of state they need
access to, rather than needing a top-level setStore function.

2. Type-safety

Essentially, we are just partially applying a setStore
function with an initial path, and returning a function that will apply the
remainder of the path. It is just syntactic sugar, and under the hood
everything is using calls to native Store functionality.

The same approach can already be used by the Setter returned by createStore. However,
Typescript users will find it hard to maintain type-safety for the arguments
passed to a "derived"/partially-applied Setter. The type definitions for SetStoreFunction are...
daunting.

The lenses package alleviates this friction by providing both StorePath<T>
and EvaluatePath<T, P> generic type helpers.

3. Shared path syntax between Getters and Setters

The path syntax defined in Solid Stores is incredibly expressive and powerful.
By introducing createScopedGetter, the same syntax can be also be used to
access Store values as derived Signals. This is particularly relevant to
child components which may both display and modify items from a Store
collection.

Closes #453

@changeset-bot
Copy link

changeset-bot bot commented May 28, 2023

⚠️ No Changeset found

Latest commit: d9a56e3

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

@nathanbabcock
Copy link
Author

Source code is inside packages/lenses: https://github.com/nathanbabcock/solid-primitives/tree/lens/packages/lenses

@nathanbabcock nathanbabcock reopened this May 29, 2023
@thetarnav
Copy link
Member

This is interesting, but why not rely on the "native" store scoping ability:

const [store, setStore] = createStore([
  {
    inner: {
      innerString: "first",
      innerNumber: 0,
    },
  },
])

const [inner, setInner] = createStore(store[0].inner)

setInner("innerString", "hello") // reflected in both stores as they share the same reference.

With stores you can rely on the references being the same, so any mutations to one will be reflected in all the other proxies that wrap the object.

I've replaced the createLens body with this, and all tests have passed:

const last = path[path.length - 1];
path = path.slice(0, -1);

const storeNode = store[0] instanceof Function ? store[0]() : store[0];
const value = getValueByPath(storeNode as StoreNode, path) as V;

const [get, set] = createStore(value as any);

return [() => get[last], (...args: any[]) => set(last, ...args)];

I don't hate the idea, but I want to know what exactly is the benefit of these primitives, as creating basic lenses is already in reach.

@nathanbabcock
Copy link
Author

nathanbabcock commented May 29, 2023

I had no idea createStore could be used in this way. For some reason I thought it would double-wrap in a Proxy, or the references wouldn't stay consistent over the lifetime of the store.

I'm pretty sure that does everything I need it to do unless I'm missing something.

@clinuxrulz
Copy link

@thetarnav
Thank you so much for that. I found I had a need for lenses on stores again, and had no idea createStore could be used like that. Was thinking using it like that would be invalid (double wrap).

That createStore lenses trick should be documented somewhere, like in the documentation for stores.

@MrJohz
Copy link

MrJohz commented Jun 7, 2024

@thetarnav, that won't work if the "lens" part can have a primitive value, right? At least, Typescript complains about that case, and I can't see how that would work.

Here's an example from a codebase I'm working on at the moment:

const [editedGraph, setEditedGraph] = createStore<{ graph: Graph | null }>({
    graph: null,
  });

Here, the graph attribute can be null (if there is no graph to be edited). I can't just have createStore(null) because null is a primitive value and not an object, and so the store proxy magic wouldn't work, hence the wrapping in an object with the graph attribute.

I would like to be able to use lenses to hide this implementation detail from consumers. In this situation, doing createStore(editedGraph.graph) won't work for the same reason that createStore(null) wouldn't work. Hence the need for lenses that can handle this case.

The getter side is easy most of the time, because you can just wrap the .graph access in a function, or use a JS property. And the setter side is easy in Javascript (i.e. without types) because you can wrap the property path magic:

const setter = (...args) => setEditedGraph("graph", ...args);

But it's very difficult to type this function properly in such a way that it still behaves exactly like the normal setStore function (i.e. that you can call it with the property path, or pass different setter functions in).

Something like createLens would be really useful for this case where I want to wrap setStore with some specific path prefix, but I still want typing to work as expected.

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.

Add createLens primitive
4 participants