New Interactivity store()
API proposal
#53586
Replies: 10 comments 27 replies
-
I think these changes provide a better Developer Experience with the Interactivity API 👍 |
Beta Was this translation helpful? Give feedback.
-
Thanks a lot for the highly detailed explanation 🙂 It's easier to understand the implications with its pros and cons. I'm in favor of something like this. I agree that the developer experience looks better. There seem to be fewer concepts, and repeating the namespace for all the directives is a bit tiresome. Actually, I usually miss it while working on new blocks 😅 . I also agree that the most confusing part for me would be not using I still have to process all the information, but I have some initial questions:
When you say this, you mean that block developers would just use In the past, it was discussed the possibility of exposing some parts, like One thing I was wondering is how does this affect extensions that modify the HTML. Imagine I have this HTML: <div
data-wp-interactive='{ "namespace": "myPlugin" }'
data-wp-context='{ "isOpen": true }'
>
<span data-wp-bind--hidden="context.isOpen">
I'm using the default namespace ("myPlugin")
</span>
</div> And another plugin (extension) change it to be something like this: <div
data-wp-interactive='{ "namespace": "myPlugin" }'
data-wp-context='{ "isOpen": true }'
>
<div
data-wp-interactive='{ "namespace": "myExtension" }'
data-wp-context='{ "extensionText": "Text with extension namespace" }'
>
<span data-wp-bind--hidden="context.isOpen">
Hello world!
</span>
<span data-wp-text="context.extensionText></span>
</div>
</div>
Would |
Beta Was this translation helpful? Give feedback.
-
Wow, that's brilliant, Luis. So much great stuff here: code splitting, type safety and a simplified API 👏👏👏 I only have some qualms about the use of generators over async/await. People will complain because they're not as nice and familiar as async/await, of course, but the main problem is that they are not composable in the same way as Promises are. There is no built-in equivalent of If wrapping an awaited expression in Alternatively, perhaps we could go even further and create a babel plugin that injects the |
Beta Was this translation helpful? Give feedback.
-
With this API, we could use getters for the pattern to persist the client state between client-side navigations (explained here): wp_initial_state([
"initialNumberOfItems" => 3
]); const { state } = store("myPlugin", {
state: {
get numberOfItems() {
return state.initialNumberOfItems;
},
},
actions: {
addOneItem: () => {
state.numberOfItems += 1;
},
},
}); Thanks to the use of getters, the signature of |
Beta Was this translation helpful? Give feedback.
-
Here are a few more ideas about this. Sometimes we've been using the ref to store values that are used as arguments in actions or derived state (previously selectors). For example, imagine a selector that checks if a div is selected. Each div has its own unique id and the id of the selected is stored in the <div data-some-id="1" data-wp-class--is-selected="state.isSelected">...</div>
<div data-some-id="2" data-wp-class--is-selected="state.isSelected">...</div> The derived state could be something like this: const { state } = store("myPlugin", {
state: {
get isSelected() {
const ref = getRef();
return state.selected === ref.dataset.someId;
},
},
}); But sometimes @DAreRodz mentioned that we should expose the props of the underneath element (vnode) because those are always defined. Instead of naming them just props, which may be confusing if we ever release a components system that also includes props, I think we should name them "Element Props". The previous example would be like this instead: const { state } = store("myPlugin", {
state: {
get isSelected() {
const { "data-some-id": id } = getElementProps();
return state.selected === id;
},
},
}); Those props are always defined, so this will always work without problems. We still need to experiment with this, because people may try to modify the Element Props and we need to think if we want to allow that and what would happen, but I think that exposing them to see what happens is a good idea. If we name those Element Props, I think we should also use the same for the ref:
Another idea we've been tinkering with is to expose another reactive state that is local to the element. This may come in handy when the state element is contained in a single element and creating a context doesn't make much sense. We still need to experiment with this, but if we do, I think we could name it Element State. Then, we will have three element-related APIs:
Using it will behave exactly as context behaves: <div
data-wp-element-state='{ "isOpen": true }'
data-wp-on--click="actions.open"
data-wp-bind--hidden="!elementState.isOpen"
>
Open by default
</div> store("myPlugin", {
actions: {
open: () => {
const elementState = getElementState();
elementState.isOpen = !elementState.isOpen;
},
},
}); If you don't need to populate initial values, you can omit the directive because the object is always defined: <div
data-wp-on--click="actions.open"
data-wp-bind--hidden="!elementState.isOpen"
>
Closed by default
</div> store("myPlugin", {
actions: {
open: () => {
const elementState = getElementState();
elementState.isOpen = !elementState.isOpen;
},
},
}); The part that convinces me the least about these names is using And last but not least, now that with this syntax the namespaces are gone, I think we could experiment with the idea of defining the property name as the suffix of the Instead of this: <div data-wp-context='{ "isOpen": true }'></div>
<div data-wp-element-state='{ "isOpen": true }'></div> you could write this: <div data-wp-context--is-open="true"></div>
<div data-wp-element-state--is-open="true"></div> That would only work for the default namespace and it will be converted to camelCase following the same logic as the dataset ( |
Beta Was this translation helpful? Give feedback.
-
While testing this out in WooCommerce, we detected two small TypeScript caveats. I don't think they are blockers, but I wanted to document them for future reference.
|
Beta Was this translation helpful? Give feedback.
-
The new store() API was merged 17 of 25 tasks are completed from the tracking issue |
Beta Was this translation helpful? Give feedback.
-
@luisherranz Having some trouble with the latest V3 of the api and RC of Gutenberg 17.2 At first I thought it was our current implementation of the api in our block library, but when I created an interactive block using the @wordpress/create-block interactivity template it also did not work. Seeing these errors: |
Beta Was this translation helpful? Give feedback.
-
I've published the migration guide for the new |
Beta Was this translation helpful? Give feedback.
-
It would be nice to support async functions, and if they're not supported a lint rule is a good idea. I know some folks are already working on or thinking about this. @gziolo, @jsnajdr, @fullofcaffeine, and myself spent some time looking for an alternative that would work with async functions. @jsnajdr proposed binding import {
getContext,
getElement,
store,
} from "@wordpress/interactivity";
const { state } = store("ns", {
state: {
get text() {
return `Clicks: ${JSON.stringify(getContext()?.clicks)}`;
},
},
actions: {
// <button data-wp-on--click="actions.clickHandler" data-wp-context='{"clicks":0}' type="button">Click me</button>
async clickHandler() {
// Fine here:
console.log(this.state.text);
// Clicks: 0
console.log(this.context.clicks);
// 0
console.log(this.element.ref);
// <button…
console.log(state.text);
// Clicks: 0
console.log(getContext().clicks);
// 0
console.log(getElement().ref);
// <button…
this.context.clicks += 1;
await new Promise((r) => setTimeout(() => r(), 1));
// The getContext in the state getter is broken here
console.log(this.state.text);
// Clicks: undefined
// The context on `this` is still correct:
console.log(this.context.clicks);
// 1
console.log(this.element.ref);
// <button…
console.log(state.text);
// Clicks: undefined
console.log(getContext()?.clicks);
// undefined
// getElement errors at this point because scope is lost.
// console.log(getElement().ref);
},
},
}); |
Beta Was this translation helpful? Give feedback.
-
When we started with the idea that eventually became the Interactivity API, we didn’t pay much attention to the Store’s API because it wasn’t part of the problem we were trying to solve, so there was no need to spend more time on it than necessary. At that time, we simply adopted the Frontity framework model because we knew it worked fine and it allowed us to keep testing the rest of the things.
Now that the proposal is out and there's a chance that the Interactivity API will get merged in WordPress Core, it’s time to reconsider every part of the system, specifically the Store’s API because the requirements of the Interactivity API are not the same than the ones that Frontity framework had. I’ve been thinking about it for the past few weeks. Last week the pieces finally started to fit together in my head, and I believe there’s a promising alternative API that fits better the Interactivity API requirements than the current API. Last Thursday, I explained it to @DAreRodz and after a thorough review, we believe it can work.
Requirements
These are some of the requirements I had in mind.
There should be as little boilerplate as possible
TypeScript: Typed stores should be as easy as writing plain JavaScript objects
TypeScript: Using stores from external namespaces should automatically import their types
Still, stores should be able to be divided into different parts and each block should load only the parts that it is interested in
Stores should be able to dynamically import logic when that logic might not be needed at startup
Consumers should not be able to distinguish between derived state and non-derived state to make refactorings of public APIs easier
Private stores should be easily isolated from other stores
Proposal
This proposal is written from the perspective of the existing Store's API, highlighting what changes are necessary. Please bear in mind when reading.
The default namespace is stored in
data-wp-interactive
We can use
data-wp-interactive
to store the default namespace of a block.In this example, both
wp-context
andwp-bind
point to a namespace calledmyPlugin
.The namespace could be defined in the
block.json
:If it's not present, we can infer the namespace from the block type:
In the server:
block.json
for the entire block.data-wp-interactive
directives to keep track of the current default namespace.As of today, I'm more in favor of using the
block.json
value for the entire block, and preventing people from populatingdata-wp-interactive
manually.The namespace is passed as a string during store creation
Instead of using the namespace inside the object, namespaces are passed as strings to the
store()
call:Stores return stable references to their properties
On store creation, stores return themselves, generating stable references that can be used to interact with the store, and therefore not require the store injection via arguments.
External stores can be accessed using the same API
When you need to access a different store, you can use the same API to get stable references to the properties of that store.
You could also destructure the store, but usually you'll have to change the names of the properties to avoid collisions with your own store.
Multiple parts of the same store can be defined in different files
Stores can still be merged together from different files/parts.
Objects returned by store() are typed
In TypeScript, we can make functions that return the same type that was passed. That means that the store returned by
store()
can be typed with the same type that you inject, even if it's an inferred type.Unfortunately, there's no way in TypeScript to merge and return the types of different calls to
store()
.So whenever more than one store part is used, types need to be defined separately and passed manually.
For convenience, types can be combined in a centralized file.
If external store parts might not always be imported when another part is loaded, they can be made optional:
Optional
would be a TS utility like this.Full examples of these types in action in Stackblitz and the TypeScript playground (with optional parts)
External consumers can import a typed store
When you want to expose your store to third-party developers, they should import it with types and ready to be used, just like any other JavaScript library.
Instead of using
store("otherPlugin")
like this:They should be able to use
import
and get the store directly from another module.To make this work, the exporter needs to export its own store with the correct types.
That's it. As long as the export is typed and the
tsconfig.json
configuration is correct, other plugins should be able to import the store already typed and ready to be used.Contextual values like
context
orref
should be accessed inside the functionsIn order to avoid the need to inject anything into
actions
orselectors
(derived state), contextual values (those that depend on where in the HTML tree the logic is executed) should be able to be accessed inside functions.Instead of this:
We could do this:
Same API for context:
Or even:
In a way, I think that it'll be easy to make a mental connection of
getRef
orgetContext
with the use ofuseRef
anduseContext
hooks, so I think they should not be hard to learn. Also, implicitly callinggetContext
might help us remove some unnecessary calls touseContext
on some of the directives.Actions are just regular JavaScript functions
Liberating actions from store or contextual value injections enables a few things:
Actions can now be called directly (like
actions.someAction()
) without having to worry about passing the store or the context to them.The type of those actions will usually be inferred automatically by TypeScript.
It's easier to add regular arguments to the functions, for example:
Derived state (selectors) can use getters
In the same way that liberating actions has benefits, liberating selectors from store or contextual value injections enables a few things. First, they can start using getters.
Instead of this:
We can do this:
Also, by adopting getters, deepsignal will convert them to computed signals, with the added reactivity benefits (vDOM bypass, correct execution order to avoid instable states…).
Derived state (selectors) can be moved back to state
But we can also merge
selectors
back intostate
. Refactorings ofstate -> selectors
can be problematic in public stores, so we should aim to make state and derived state indistinguishable from each other.For example, imagine that in this store:
state.someValue
needs to be refactored into a derived state:The public API would change from
state.someValue
toselectors.someValue
.For that reason, I think we should promote the use of state also for derived state:
The public API was
state.someValue
and continues beingstate.someValue
even if the value is now derived.state.someValue
(real)state.someValue(store)
(derived).context -> selectors
, but I'm less sure that this pattern will be adopted because it would mean one extra getter for each context property. For plugins with very sensitive public APIs, it might make sense. But for small plugins maybe the extra boilerplate is not worth the effort.Defining the initial state in the server
If we move the derived state to the
state
property, there's no need to use the longwp_store
format anymore: thestate
property can be absorbed in the function name (wp_initial_state
) and the namespace can be passed as a string.Instead of this:
We can write this and it will have the same information:
Types from the initial server state
The types of the state defined in the server can't be inferred in TypeScript because they are defined in PHP. For that case, they can be added to the
Store
definition manually:Types are not required for the derived state because it already has a representation in the client and therefore it should be already typed.
Accessing other namespaces in the directives
Accessing other namespaces is also possible when using directives. My proposal is to use the following syntax:
I also like the idea that deviating a little bit from the illusion that what people write in the attribute value is regular JavaScript will help decrease confusion and misunderstandings, but still keeping a syntax familiar enough that is still easy to learn.
Creating and accessing context from other namespaces in the directives
Creating and accessing context from other namespaces would use the same
"namespace::"
syntax:Accessing context from other namespaces in the store
The
getContext
method used inside the store is hooked to the default namespace of that store.If you call that action or state from another store, it will still access the namespace of the original store:
It's important to differentiate between:
Scope influences the element that is returned by
getRef
, or the specific merge of context objects returned bygetContext
. It doesn't change in respect of how it works today.You can access the context of another namespace in a store by passing the namespace to the
getContext
function.Typing contexts
As context is defined in PHP, manual typing is required.
Plugins can export the type to be applied manually to
getContext
.Or they could export a typed custom
getContext
function, whatever they prefer.TS playground example
Async Actions should use generators instead of async/await
To liberate actions and selectors from store injection, there's one caveat: actions should use generators instead of async/await.
In the past, I've fought a lot against ditching async/await and we were able to create an API for Frontity framework that worked with async/await beautifully. But here the gains of liberating actions and selectors from store injection outweigh the cost of writing
function*
instead ofasync ()
andyield
instead ofawait
.The problem is that, in async functions, the control is passed to the function itself. The caller of the function has no way to know if the function is awaiting, and more importantly, if the await is resolved and the function has resumed execution. We need that information to be able to restore the scope.
Imagine a block that has two buttons. One lives inside a context that has
isOpen: true
and the otherisOpen: false
:The action is async and needs to await a long delay.
isOpen: true
.state.isOpen
is correct becausegetContext
returns the current scope.isOpen: false
.state.isOpen
is correct becausegetContext
returns the current scope.state.isOpen
of the first action is incorrect, becausegetContext
now returns the wrong scope.We need to be able to know when async actions start awaiting and resume operations, so we can restore the proper scope, and that's what generators do. The previous store would work fine if it'd have been written like this:
In a way, it's the same problem that prevents (P)React/Vue components to support async/await because if they would do, they won't be able to recover the state of their hooks after an
await
.There's an alternative API, which would be to wrap all the
await
calls in a utility function, like this:But I think it's easier to forget to use
resume()
in all yourawait
calls than to use generators on all your async methods.Stores should be able to import logic dynamically
When store parts are imported asynchronously, they are also added to the same store references and can be accessed normally. That means that stores can import other parts that they need.
This method works great because
import()
won't do anything if"other-store"
has already been imported, no matter if it was from a previous call to this action, another action, or from a block ofother-store
that required it. It just ensures that it is loaded at the time the action wants to access the other store.Stores should be able to build façades of their public APIs
This method can also be useful to expose a public façade that contains the skeleton of the full public API, but it doesn't need to contain all the code of the full store.
Imagine a store with these two parts:
If this plugin wants to expose a public API that other plugins can use, but it doesn't know how many actions the plugins will use, it can build a façade that only contains the skeleton of the store and imports the actions on demand.
Calling the action for the first time will be slightly slower, but this way only actions that are actually used (and their required store parts) are downloaded and executed.
The only requirement for this method is that all actions should be asynchronous by default, and plugins need to await (
yield
) when they call them if they need to access any modified state from that store after the action execution.The same method can be applied to derived state, only that derived state needs to be asynchronous so the first time will need to return some temporary value.
Once the
store-dynamic-part.ts
file is loaded, their call tostore()
will replacestate.someDynamicValue
with the real logic, and the directives/effects that depend on it will be reloaded again.Private stores
This new API also works better with my last idea for private stores because each store is its own object and therefore it's easy to be protected with a
Proxy
, like the method used in the private-apis package.For plugins with single file stores, its usage is very straightforward because the private store returns the store references:
However, the protection is that
privateStore()
can only be called once for the same private namespace or it will throw. Plugins with multiple store parts can get a hash that allows executingprivateStore
multiple times for the same store.Passing the
unlock
hash preventsprivateStore
from throwing and the hash cannot be accessed externally, so it's safe.If an external plugin wants to access the
"myPrivatePlugin"
store, it needs to sign the call toprivateStore
to prevent it from throwing:Private stores should not be accessed unless people are testing/experimenting so not having the option to import them already typed using
import
should not be a problem.For discussions about this API, please refer to the original discussion.
My next step is to try to build a prototype with this new API and see if all the pieces work fine or if I'm still missing something.
Beta Was this translation helpful? Give feedback.
All reactions