Crank: Equivalent to React.createContext() with Provider and Consumer (or alternative)?

Created on 16 Apr 2020  Ā·  31Comments  Ā·  Source: bikeshaving/crank

Is this being implemented or did you have any thoughts about someone might implement it in this library?

Basically, what is the strategy for avoiding props drilling down multiple layers of components?

documentation

Most helpful comment

I haven’t documented it yet, but you can pass data from ancestor provider components with this.set, and access them in descendent consumer components with this.get. The key of each provision (that’s what I’m thinking of calling this feature since I already call this ā€œcontextsā€) can be anything, and the values can be anything, and behind the scenes it’s backed by a ES6 map. There are no special bells and whistles like forcing consumers to rerender when the provider sets a provision, it’s just a simple way to pass values without direct prop drilling.

I’m trying to figure out where in the docs this info goes.

All 31 comments

I haven’t documented it yet, but you can pass data from ancestor provider components with this.set, and access them in descendent consumer components with this.get. The key of each provision (that’s what I’m thinking of calling this feature since I already call this ā€œcontextsā€) can be anything, and the values can be anything, and behind the scenes it’s backed by a ES6 map. There are no special bells and whistles like forcing consumers to rerender when the provider sets a provision, it’s just a simple way to pass values without direct prop drilling.

I’m trying to figure out where in the docs this info goes.

I’m trying to figure out where in the docs this info goes.

I'd add a new subpage for common React equivalences

Awesome! Coming up with a good naming convention is definitely recommended, as I guarantee people will be confused about these concepts of contexts.

Reopening this for visibility and to keep track of when it’s documented. I apologize if you didn’t want notifications about it!

Could you give some example of how would you update component on context value change or it's not to be confused with what React Context is?

@lukejagodzinski it's _similar_ in theory to React Context (a way to share data with children without passing data as props). here's an example of updating a child component when parent context changes:

https://codesandbox.io/s/wispy-hooks-6o3jz

/** @jsx createElement */
import { createElement } from "@bikeshaving/crank";
import { renderer } from "@bikeshaving/crank/dom";

function* Number() {
  for (let _ of this) {
    yield <h1>{this.get("number")}</h1>;
  }
}

function* App() {
  let id;

  try {
    let number = 0;
    id = setInterval(() => {
      this.set("number", number++);
      this.refresh();
    }, 200);

    while (true) {
      yield <Number />;
    }
  } finally {
    clearInterval(id);
  }
}
renderer.render(<App />, document.body);

@ryhinchey right makes sense. Actually it was pretty obvious, don't know why I asked this question. It's probably because I'm still thinking in React Hooks way :) where you expect it to refresh kinda automatically :). But now seeing an example it makes perfect sense :). Thanks!

@lukejagodzinski Yeah I initially had a relationship between provider and consumer where if a child calls this.get, the parent which provided it would also update the child when the parent called this.set again. After some thought, I decided that this went against Crank’s design philosophy, which was to be executionally transparent, and if you want to make sure a child is always updated when a parent sets the context, you should be passing in an evented object from the parent and have the child listen to that object. Typically contexts are for library authors trying to do advanced logic over nested element trees, and I thought this tradeoff was both acceptable and led to easier to understand codebases.

@brainkim I think Crank could totally divert from React beside JSX (however I would support other things like htm or esx). Most people will have hard time doing switch. For example, I was wondering how to communicate with parent from children. Automatically, I thought about React Context but later I realized that in Crank I could just emit event and listen to it in the parent. To implement that in React requires a lot more code to write and a lot of application logic is just hidden.

I think there could be some documentation section which teaches how to implement some React concepts in Crank. There is a lot already explained in docs but I would go even further. Just some React snippet and Crank equivalent below it - kinda like cheatsheet :).

I really want to start using this library. Maybe I will try it on some side project to see how much I'm still missing :). Btw. have you done any performance tests? I guess it could be much faster than React

It would be nice if we could subscribe to changes somehow, so that a component could re-rerender when a context value changes.

@kyeotic My personal recommendation is to pass an evented object directly, so you add an event listener in the consumer which refreshes the context explicitly, and trigger the event from the provider whenever necessary. The alternative is to refresh the provider whenever you need to change the context, which should work except for the edge case where there is a Copy element directly between the provider and consumer.

One thought I’ve been having while doing a documentation pass of new features. What if we renamed the get and set methods to provide and consume? This would match the terminology used in the docs. And it would also make things a lot more searchable: I’ve been having a lot of trouble jumping to providers using just the get and set methods because the words are so common. Maybe we could deprecate get and set, but given that Crank is a beta and isn’t used that much currently I’m thinking about ripping the bandaid off and changing the API in one go for 0.3

Actually that makes a lot of sense, using an emitter as the context value is perfect because it gives the component control of when to re-render (if at all.)

As far as the method names, I think being explicit would help. this.context.get()/this.context.set(). At least for me, this really is about providing access to application-level objects, like the store or the http client. I want them to be instanced, not singletons accessed via import.

@brainkim over weekends I've started doing simple app using Crank and as it works great overall, I've encountered several issues when using context set/get methods.

  1. When developing app I was missing a lot of documentation of how the inter-component communication works. However, going back here I see that I forgot that the idea of communicating between parent and children is to send event from parent so that children can refresh itself. Right now I'm updating context at the top level and I'm refreshing an entire tree which is not ideal. So the idea would be to send ADD_TODO when we want to update parent context and then inform children interested in this change by sending the TODO_ADDED event from the parent context. Actually it's pretty elegant, maybe it's a little bit more code to write but I will try that. So my first problem might actually be solved :)
  2. The next problem I have is that I'm missing documentation on how it actually works. How is it being reused between components or is it reused at all? How passing context props is being done? Is it like context props set on component X are visible only to all its children? From my tests it's true. But I would like to know more implementation details without reading code, so good documentation would be helpful.
  3. In React we can have several contexts in one components tree. Here if I wrap one component with another one and they happen to have properties with the same name then the will override previous value, right? So in the future, when we will have some third-party components that are using context then name conflicts might be problematic.
  4. After using it I also don't like the overall idea of having all this values in the this object. I think you said once that you want the API to be transparent but the this context itself hides a lot of logic in the app. I would prefer to wrap my tree branch with the Provider and name it somehow and only have state being stored there and to make it not interfere with other contexts. So I would just externalize this concept and it would make entire Crank API much easier to reason about. Actually I've created in this app some Provider component but I'm still using the this context. I will propose soon some solution for that and I will actually share codebase of this app.

I will give more feedback soon. Overall I'm happy with Crank but at several stages I was missing React and event started rewriting app in React but then went back to Crank because I didn't want to give up too soon. Actually I reverted back to Crank when I needed to invoke some async functions :P so the main idea behind Crank is actually keeping me from moving away from it. I'm just testing Crank on side project to test it thoroughly and if I confirm that it's worth switching to, maybe I will convince people at work that we might rewrite some smaller apps to use Crank :)

If you use Symbol as a key in a map you can ensure that there won't ever be a conflict. This helps with 4ļøāƒ£; it gives it a name that children can access and stops it from interferring with other contexts.

@kyeotic hmm I haven't thought about that. Actually that might be really good solution but it's still easier to just have string keys instead of defined somewhere symbols and importing them every time you want to access state. I also wonder if symbols would work with autocompletion?

Oh and I reminded myself about 5th problem. It's actually bad user experience when using it in TS and extending interfaces. Overall I don't like extending interfaces and here I have to do it a lot for both events and context. One way of solving it would be to create some higher level API that extends interfaces automatically but I still have to play with it. Just leaving it here as a potential problem.

Autocompletion is definitely a weak-point for using keys-as-symbols. You can use strings, but then you are risking potential collision of features try to use the same string. Its manageable, and if autocomplete is high priority it might be worth it.

However, symbols can still use autocomplete! You just have to put in a little more work. Its possible in typescript to include symbols in the type, so you can use a union of all the symbols as the parameter type and get autocomplete. It will even import the symbol if you are using VSCode!

@kyeotic However, symbols can still use autocomplete!

That's great!

@lukejagodzinski

When developing app I was missing a lot of documentation of how the inter-component communication works. However, going back here I see that I forgot that the idea of communicating between parent and children is to send event from parent so that children can refresh itself.

Yeah you can build yourself something like Redux, but I strongly recommend that you try using this.dispatchEvent and custom events first. Even if you don’t choose to use this API in the end, the event capturing/bubbling/canceling works exactly as it does in the DOM, meaning you’ll at least have learned how event targets/custom events work.

The next problem I have is that I'm missing documentation on how it actually works.

I’m working on docs as we speak! The big problem I’m having with documenting provisions (contexts) is that I’m finding it difficult to find motivating examples for contexts. Use-cases like theming are better solved with css variables, and I don’t think that authentication or localization strings are good use-cases either, insofar as there is only one user/localization per app. Almost none of the use-cases ever need multiple of the same of context provider per tree; for instance, there is no situation where part of the tree is rendered with one active user and part with another. They’re all just stacked on top of each other at the root.

I like this tweet and the discussion below it:

Screen Shot 2020-07-14 at 11 49 51 AM

Except maybe I’d say something more like, all the things you’re putting in contexts belong in global singletons. This is bound to be an unpopular opinion, but I really do think having global evented singletons makes way more sense for things like users/localization strings.

But I would like to know more implementation details without reading code, so good documentation would be helpful.

Crank’s core is a single file! I’m trying to make the core as approachable as possible. I’m also like 20% done with a blog post on writing Crank from scratch!

name conflicts might be problematic

Yep as @kyeotic says, using symbols will prevent the name collision problem. You can use actually use anything for a provision key, because internally it’s stored in a map.

After using it I also don't like the overall idea of having all this values in the this object. I think you said once that you want the API to be transparent but the this context itself hides a lot of logic in the app.

Certain things in Crank are implemented as context methods, and others are implemented as special element tags, and there isn’t really a hard and fast rule for when I used which API; it was more a matter of experimentation and taste. There are some clear cut cases. For instance, it’s really hard to think of ways to implement Fragment or Portal elements as methods. With provisions, I avoided the tag-based API of Provider and Consumer, because React Consumer elements use render props, and I didn’t want to encourage that practice in the core. I thought that using a method would be more elegant, but if you have alternative ideas I’d love to hear them.

That being said, you can build the Provider and Consumer elements directly on top of Crank as follows:

function createContext(key) {
  return {
    Provider(props) {
      this.set(key, props.value);
      return props.children;
    },
    Consumer(props) {
      const value = this.get(key);
      return props.children(value);
    },
  };
}

I haven’t tested this code, and it doesn’t create a special link between Provider and Consumer, but as you can see, using a method here allows you to define React Context patterns directly in user space. I don’t think this is cranky code; typically, provisions should be used to do communication between special components, like the draggable and droppable components you would write in a drag and drop library.

Overall I'm happy with Crank but at several stages I was missing React and event started rewriting app in React but then went back to Crank because I didn't want to give up too soon.

If you ever have any questions or whatever I’m pretty available to help people experimenting with Crank. I’ll even do code review, though I can be kinda annoying and opinionated haha.

I like this tweet and the discussion below it:

Hmmm actually as I think about it right now, maybe using context for storing my application state is not the best thing to do. I've used context mainly to pass the selected element on the list. Let's say I have several nested components like <Something><List><Item>... and if I store state above Something and I have there reference to selected item, then I would have to drill prop down through all the components. But using the ITEM_SELECTED event would actually solve problem of props drilling. So no need to use context. So maybe the tweet's author is right that it's should be mainly used for libraries. But it's really tempting to have some state abstraction but that might be done just by externalysing it into separate file. I will play around with it. And provide feedback.

I’m working on docs as we speak! The big problem I’m having with documenting provisions (contexts) is that I’m finding it difficult to find motivating examples for contexts.

Great! Hmmm right, now I'm also not convinced about good context usage example. Actually with events system context could be avoided entirely, I guess. I will think about some good example.

I’m also like 20% done with a blog post on writing Crank from scratch!

That would be great! I like this kind of articles/tutorials. It's always good to know internals of the library that you're using.

With provisions, I avoided the tag-based API of Provider and Consumer

I like Provider/Context pattern because it just externalizes some logic but I get the point of not using it. Actually, it's very well known problem when we have to wrap several providers in one place to just provide data to children components and nesting them is kinda like wrapping dumb components with the containers providing data. It's a lot of wrapping which isn't great. The tree of components grows really fast. I think that using JS built in building blocks is really powerful and some patterns might emerge from the community.

Mmmh, personally I do not have problems with the fact that these this.set(..) and this.get(...) methods are extremely low-level and that they do not perform any component refresh at all.
But which non-trivial use cases are there in the real world where I really would like to use these low-level set and get methods directly?
In more than 90% of all non-trivial use cases that I can think of, provision values (aka context values) depend on props of some provider component and all consuming sub components shall of course automatically update as soon as the provision value (aka context value) changes, even if they are descendants of memoized components (=> <Copy/>).

@brainkim Frankly, I do not know what exactly you mean when you use expressions like "executionally transparent" - does it mean, it should always be explicitly visible what side effects are performed? In that case, is any effectful userland helper function NOT "executionally transparent" and should be avoided cause it would be a violation against "Crank’s design philosophy"?

Achieving a context (aka provision) like behavior similar to React without using some helper functions seems like a lot of code repetition to me, am I wrong?

For example the whole thing could be implemented with two userland helper functions defineProvision and consumeProvision similar to the following example where I would not have to care about the (internally used) provision map keys and TS typing will not be a problem at all (but this may not be "executional transparent" - honestly, what would be the problem with that?).

import { defineProvision, consumeProvision } from '@userland/crank-provision'

const [ThemeProvision, ThemeProvider] = defineProvision('light')
// where ThemeProvider is a component similar to React (=> <ThemeProvider value="dark">...</ThemeProvider>)
// and `ThemeProvision` implements type `Provision` like follows
//
//  type Provision<T> = {
//    hasDefault(): boolean, // just added for typing reasons (see also `getDefault`)
//    getDefault(): T, // throws an error if the provision does not have a default value (use hasDefault first)
//    getValue(context: Context): T // throws an error if error if no value available
//  }

// Alternatively:
// const [ThemeProvision, setThemeValue] = defineProvision('light')
// // where `setThemeValue(this, someValue)` will set the provision value
// // for a given component context (=> `this`)

// [Edit: some changes]
// Or even this alternative - while not really my favorite:
// const [setTheme, consumeTheme] = defineProvision('light')

function* MyThemedComponent(...) {
  ...
  const getTheme = consumeProvision(this, ThemeProvision)
  // using the second alternative it would be: const getTheme = consumeTheme(this)
  ...
}

@mcjazzyfunky actually I did something like that in my tests. The consumeProvision function was just collecting all the contexts and then whenever value change it was refreshing all those components whose contexts I've collected. It worked for sync and async components but didn't work for generators. I don't know why but maybe it might be solved if officially supported by Crank. It really felt like nice API, similar to React. But why I liked it might just come from getting used to using React for such a long time.

Guess the following will not be very popular, but I think another option could be to remove the methods get and set from class Context as everything you can do with those methods can also be implemented in userland based on addEventListener and dispatchEvent (at least I think it should be possible). Of course you'll need some userland helper functions for that, otherwise things will be quite cumbersome. This would have the advantage that the final decision whether direct support for provisions should be added to Crank's core and how the provision API should look like could be postponed to a later Crank version sometime in (hopefully near) future. The current provision API (=> this.set(...)/this.get(...)) does not really seem very mature to me (naming possibly not final .... not easily/concisely usable for many use cases ... current TS typing seems to be a bit odd ... etc).

Ok I was testing whether the context could be replaced with just this.addEventListener and this.dispatchEvent and from my understanding it can't. Let's say I have the components tree like here:

<List>
  <Item></Item>
  <Item></Item>
  <Item></Item>
</List>

Now I store state for the selected items in the List:

function* List() {
  const selectedItems = [];

  this.addEventListener("app.select-item", (event) => {
    const item = event.detail.item;
    selectedItems.push(item);
    this.dispatchEvent(new CustomEvent("app.item-selected", { bubbles: true, detail: item })));
  });
}

You can see that I'm listening to the app.select-item event which is supposed to come from the children Item elements. After receiving item to select, I'm also informing all the children components about selected item, but am I? Events bubbling and capturing only goes up to the root and back but doesn't go beyond Item element to its children, right?

I've quickly tried it and it didn't work. No children was receiving app.item-selected event and that actually makes sense considering how event bubbling/capturing works. Crank implementation would have to break this rule. Am I missing something here?

EDIT:

Here is reproduction: https://codesandbox.io/s/crank-event-propagation-gh9bc
Item components do not receive app.item-selected and app.item-deselected events.

EDIT2:

Actually, I was implementing something like that for my meteor-astronomy library. There it was dispatching events to all children of the object by default https://github.com/jagi/meteor-astronomy/blob/v2/lib/modules/events/class_prototype_methods/dispatch_event.js#L56
Right now I think it would be best to provide some dispatchEvent option that tells Crank in which direction it should propagate event. Or just create some other method that only passes event down the tree, wdyt?

Somewhere above I've claimed that for implementing a provision API in userland context.get and context.set are not really necessary as the provision functionality can also be implemented by using addEventListener and dispatchEvent instead.
Here's a little demo that hopefully helps a bit to show what I've meant:
https://codesandbox.io/s/crank-provision-nlfmf?file=/src/index.js

I've implemented the following simple (one-function-only) provision API:

const [provideTheme, consumeTheme] = defineProvision('light')

Please be aware that this is just a quick'n'dirty proof of concept. I'm sure there are a lot of bugs and component updates are performed in a most simple and unoptimized way.
If you wonder about the unusual implementation: One target was to call dispatchEvent as rarely as possible, that's why things may be a bit different than expected.
By the way, I think a full implementation would need a context.isMounted() method in Crank core.

@lukejagodzinski

Ok I was testing whether the context could be replaced with just this.addEventListener and this.dispatchEvent and from my understanding it can't.

What you can always do in such cases is at least the following:

This was your example:

<List>
  <Item/>
  <Item/>
  <Item/>
</List>

I think my demo proof of concept above has already shown that a basic provide/consume mechanism is possible in Crank based on addEventListener and dispatchEvent.
Now: Component List provides a (let's call it) ListController. Component Item consumes this ListController. Message-passing from Item to List will be performed by Itemsimply calling methods of the ListController.
Message-passing from ListController to Item will be implemented by allowing to listen (aka. subscribe) to certain events of ListController (can be simple listController.addWhateverListener(callback) methods or RxJS observables etc.).

@mcjazzyfunky

Frankly, I do not know what exactly you mean when you use expressions like "executionally transparent"

This is fair. ā€œExecutional transparencyā€ is a term I invented to attempt to carve out a space for myself, while everyone talks about ā€œreferential transparencyā€ and ā€œpure functions.ā€ I don’t have a precise definition, but we can just say that your code is ā€œexecutionally transparentā€ when you know when and how many times it is executed.

does it mean, it should always be explicitly visible what side effects are performed?

No, it just means that Crank calls your code in predictable manner. If provisions had some kind of subscription mechanism where consuming a provision meant that we update all consumers whenever providers update, we’d be decreasing executional transparency, because all of a sudden there’s another reason why your code might be executed.

@lukejagodzinski

Your example doesn’t work because events can’t ever propagate to descendants. Here’s a way to think about it. A Crank element tree can actually be considered to have two trees. The first, is the element tree we know and love, which is created during rendering. The second, is the context tree which links all component elements together. You do not have any means to traverse the context tree directly (contexts keep a reference to their parent for implementation purposes, but it’s a private member and you should not reference it at any point for any reason).

Instead, we have two APIs to communicate data to ancestor or descendant contexts. First is the EventTarget API and the dispatchEvent API. This API works exactly as it does for the DOM, and by using event bubbling and capture you can propagate data to ancestors. The only thing that ancestor contexts can communicate back downwards is a cancellation boolean, by calling ev.preventDefault on the event which was dispatched. You could probably find other ways to hack around the EventTarget API to pass more data downwards, but note that no matter what you do, dispatchEvent will always still have to be called on the child context.

The other API for tree-level communication is the provision API. Itā€˜s necessary because when we’re creating the element tree, parent contexts are necessarily created before child contexts, because the only way to know which children to create is to call the parent components first. We don’t always want to pass data with just props, so we can use the context API to continue to pass data between components, using more flexible element compositions.

@mcjazzyfunky

The current provision API (=> this.set(...)/this.get(...)) does not really seem very mature to me (naming possibly not final .... not easily/concisely usable for many use cases ... current TS typing seems to be a bit odd ... etc).

This is very true, but hopefully my explanation shows why we might need this kind of API. I continue to believe that most uses of the Context API in React are unnecessary (specifically any use-case which sets a provider at the root of the application). But I do think there are legitimate use-cases, like doing a user-space select/option form component, or drag and drop. I also am currently thinking of ways we can use provisions to emulate React’s SuspenseList component directly in user space. The SuspenseList and Suspense components have to interact based on when their promises fulfill, so it might be a good use case for provisions, I just need to figure out the best way to implement it.

@brainkim

If provisions had some kind of subscription mechanism where consuming a provision meant that whenever the provider reset the provision, all consumers are automatically updated, we’d be decreasing executional transparency, because all of a sudden there’s another case which might cause your code to re-execute.

Does that imply that context.refresh must only be invoked directly within the component function itself and asynchronous invocations of context.refresh somewhere in some helper functions are a violation to primary Crank principles?
That would be quite a limitation, wouldn't it?

The automatic update of context consuming components, when the surrounding context has changed, is an elementary feature of contexts in React.
I'm really looking forward to the Crank documentation update that will show how this "React context" behavior can be implemented in Crank - I really have no idea, how it will be possible to implement this (which is basically 3-4 lines of code in React) in a (re-)useful manner without using helper functions.

Does that imply that context.refresh must only be invoked directly within the component function itself and asynchronous invocations of context.refresh somewhere in some helper functions are a violation to primary Crank principles?
That would be quite a limitation, wouldn't it?

No, I can think of reasons why a plugin or something would be passed a context and refresh it on behalf of the user. There are lots of good reasons to decrease executional transparency by having your components rerender automatically or less predictably. What I’m focused on is Crank’s contribution to how opaque your code gets. We can say as an invariant that the number of times Crank executes a component is equal to the number of times it is updated by a parent plus the number of times the refresh method is called. The situation gets more complicated when we throw in promises, but even then, it’s well-specified. This is the invariant that allows me to say that you can put side effects directly in the render function, and it allows for a kind of reasoning which is completely lacking in most other frameworks.

In 0.3.0 the methods have been renamed to provide and consume to match terminology and make it a little more greppable. Sorry if this breaking change is disruptive. It’s also now documented in the guide for reusable components making it a little more official, but I am still a little unsure about the behavior.

If you have a better example for documentation purposes I am all ears! Closing the issue for housekeeping purposes.

I'm a little concerned about how try/finally plays with cleanup. Which runs first? Should certain cleanup be done in one vs the other?

@kyeotic Probably goes in another issue, but it’s a good question!

All cleanup callbacks are called before generators are returned. The reason is that I imagine people are going to start writing event stream async iterators which return when the component is unmounted. If these async iterators rely on the cleanup method to do so, there might be potential deadlocks when returning the component, because an async generator which is awaiting a promise won’t resume immediately when its return method is called. In other words, while the developer might think an async generator is paused at a yield operator, it’s very possible that the async generator will instead be paused on an await operator. Having cleanup fire first means that any promise-based utilities you write can fulfill or reject in a timely manner by using the cleanup callback.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

xkxx picture xkxx  Ā·  7Comments

palavrov picture palavrov  Ā·  6Comments

Hezhong123 picture Hezhong123  Ā·  3Comments

brainkim picture brainkim  Ā·  5Comments

virtualfunction picture virtualfunction  Ā·  5Comments