Skip to content

Instantly share code, notes, and snippets.

@SteveSandersonMS
Last active June 26, 2023 02:54
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save SteveSandersonMS/d330c59bc815f7595af1443b2b79e0fa to your computer and use it in GitHub Desktop.
Save SteveSandersonMS/d330c59bc815f7595af1443b2b79e0fa to your computer and use it in GitHub Desktop.

Cross-component interactions

Blazor should make it easy to build all the classic high-level UI widgets, such as forms, grids, tabs, dialogs, and so on. One of the big technical pieces needed for this is templated components, and since 0.6.0 we have strong support for that. Another necessary bit of infrastructure is a mechanism for components to collaborate based on ancestor/descendant relationships. For example:

  • In a Form component, you'd want nested Input components to be able to affect the form's validation on submit.
  • In a TabSet component, you'd want the TabSet to discover nested Tab components so it knows what tab titles to display, and to render only the active one (either by TabSet choosing which child to render, or by Tab knowing whether it's currently the active one).
  • In a NavBar component, you'd want a HamburgerButton component to be able to send a "toggle expansion" signal to the NavBar.

As of today, all these are possible but only through manual wiring. For example, the developer must manually pass a FormContext to an Input component, or must manually put an onclick event on the HamburgerButton and write code that calls some "toggle" method on the NavBar.

It would be better if the wiring work could be done by the component author, so the component user was not burdened by it. This would be possible if components had a way to colloborate based on ancestor/descendant relationships.

Since the beginning, we've talked about doing this using a mechanism similar to React's context. That would work, but after some investigation, I think we can take advantage of .NET capabilities and patterns to make APIs that feel more idiomatic and more convenient still.

Comparison: React

React has two APIs for this.

React's old (now deprecated) API: context

The context is a name-value collection made available to descendants. The set visible at any level is all the name-value pairs provided by ancestors, with closer ancestors overriding those from more distant ones when names overlap.

To provide values, component developers declare statically which names they will provide values for (via MyComponent.childContextTypes) and implement getChildContext, which the framework calls when any descendant declares it wants a matching value.

To consume values, component developers declare statically which names they will read (via MyComponent.contextTypes), then will receive a context parameter to lifecycle methods such as componentWillReceiveProps.

This covers scenarios like:

  • Passing static values to all descendants, e.g., theme colors
  • Passing changing values to all descendants, e.g., logged-in username or current nav location.
    • To notify of a change, the component triggers a state change on itself (even if its state hasn't changed), then returns different values from getChildContext.
    • To receive changed values, the consuming component gets more calls to componentWillReceiveProps when the values change
    • Alternatively the value passed in the first place could be observable in some way, e.g., an event emitter.
  • Having a component know about its own descendants
    • e.g., the ancestor can put some kind of "registerDescendant" function in context, then the matching child instances can call it (passing this) during their initialization
    • The descendants would also have to manually "unregister" themselves when they are being removed
    • This is not exactly simple or obvious, but does technically work

This older API led to problems with rendering:

  • Because of how rendering propagates in React, changes to context values would fail to propagate if there was any intermediate component between the provider and consumer that returned false from shouldComponentUpdate. In general you could not trust the system given arbitrary component trees.
  • More broadly, it was not inherently part of the render refresh cycle. Developers could read context values in all sorts of lifecycle methods, leading to many bugs whereby changed values apparently didn't make the right things re-render (because the rules around which lifecycle methods fire when aren't always obvious), and people couldn't easily make sense of why.

React's new API: Providers and Consumers

In response to the problems above, React recently released a replacement set of APIs that are designed to be an inherent and unambiguous part of the rendering cycle. It's based around two built-in components, Provider and Consumer.

To define a value that can be accessed across the hierarchy, developers call React.createContext(). This returns an object like { Provider: ..., Consumer: ... }, where both values are React components.

To supply a value to descendants, a component wraps part of its output in the provider component, e.g., given const theme = React.createContext(), you can do:

    <theme.Provider value={{ themeName: 'retro' }}>
        <SomeOtherComponent />
    </theme.Provider>

To read a value set by an ancestor, a component needs to have access to the consumer returned by React.createContext (e.g., from a module export), then it can do:

    <theme.Consumer>{ val =>
      <div className={val}>Hello</div>
    }</theme.Consumer>

Notice that:

  • The consumer is a HOC that needs you to pass a render function as its sole child. It's a pretty weird-looking construction but people get used to it.
  • The supplied value can only be obtained during rendering. You cannot directly see it during the descendant's constructor or other lifecycle phases such as componentDidReceiveProps.
    • As a workaround for this, the descendant can pass the value as a prop to yet another child, which then sees it as a normal prop.
  • The change notification mechanism is inherently part of the normal rendering cycle. If the ancestor renders and passes changed props to theme.Provider, then the framework knows to re-render any attached theme.Consumer instances. Intermediate components can't affect that, and developers can't be confused about which lifecycle methods are notified.

Drawbacks:

  • Non-obvious syntax for consumption
  • Consumption only receives values during rendering, forcing extra hops if (e.g.) you want the value to be used when fetching some data
  • If you want to consume many values, you have to nest all the consumer components. It can look pretty mad. Workarounds do exist, albeit non-obvious ones.

Direct access to parents and children

React deliberately does not expose APIs to let components directly access the parent component instances.

Similarly, although it does support ref as a way of capturing a reference to a specific child instance, it does not have an API to get the list of all child instances dynamically.

The reason for this is that any usage of such APIs would be antipatterns. It would lead to components being tightly coupled to specific hierarchies (imagine this.parent.parent.parent... or this.children[2].children[0]...). Instead, developers are expected to use Provider/Consumer APIs to let components collaborate, which is also more testable.

Comparison: Vue.JS

For hierarchy-based collaboration, Vue offers simpler APIs than React does. It can get away with this because Vue has reactivity (observability) built-in, so it doesn't really have to address the question of propagating updates - if a shared value is reactive, then consumers can observe changes without needing any different APIs for that.

Vue's terminology for this feature is dependency injection. It means something much simpler and less opinionated than ASP.NET does.

To provide a value to descendants, a component declares a provides object or property:

const MySite = {
  ...,
  provide: {
    themeName: 'retro'
  }
}

To receive a value from an ancestor, a component declares inject:

const MyDescendant = {
  ...,
  inject: ['themeName'],
  created() {
    console.log(`The current theme is ${this.themeName}`);
  }
};

The syntax for providing reactive values is a bit more involved, but doesn't affect the syntax for consumers. Presuambly the syntax does get more complex if you're using TypeScript, because in the preceding code sample, TypeScript wouldn't know that this.themeName existed.

Overall this is pretty nice, in that it has the same usage simplicity as React's old (deprecated) model, but without the potential for bugs related to propagating updates.

Direct access to parents and children

Vue does allow components to access this.$parent and this.$children. However, the docs caution:

Use $parent and $children sparingly - they mostly serve as an escape-hatch. Prefer using props and events for parent-child communication.

Overall the Vue community frowns on these APIs for the same reasons that React refuses to have them. Their usage breaks testability and leads to unmaintanable cross-component dependencies.

Comparison: Angular

Angular has @ViewChildren and the ability to use DI to inject the parent component as a constructor parameter. In effect, this is like this.$parent and this.$children in Vue, with most of the same issues, though it does work a bit better for unit testing since you can set those values in tests.

Possible designs for Blazor

To recap, relevant scenarios are:

  • Passing static data to descendants (e.g., form context, theme info)
  • Passing changing data to descendants (e.g., login or navigation state)
  • Discovery of descendants (e.g., to auto-wire-up all the tabs to a tabset)

Some ways we could approach it:

1. TreeContext

This would be a hierarchy-aware key-value store, similar to Vue's provides/inject and React's older context (but without the propagation problems).

Any component could read or write values to TreeContext. When reading, the values would be those set by ancestors.

Pros:

  • Simple to use
  • Fits well with unit testing

Cons:

  • No built-in update propagation. To supply values that can change, Developers would need to pass objects that expose events. Consumers would need to subscribe manually, and remember to unsubscribe during disposal.
    • Basically, it's not an inherent part of the rendering cycle, so it has many of the same drawbacks as React's deprecated context.
  • Does not directly solve discovery of descendants. Developers would need to supply a "register child" delegate, so that descendants could call it, passing this. In turn this is extra awkward because of our current issues with this being typed as __MyComponent not MyComponent.

I implemented this as a prototype. After doing so, and attempting to use it in a few scenarios, I realised that in every case the objects I was putting in TreeContext were actually component instances (sometimes typed as interfaces implemented by those components), because that made it easier to declare methods invokable by descendants (e.g., RegisterTab or AddValidator). This leads to a possible simplification...

2. GetClosestOfType<T>

If in practice it's only interesting to find ancestor component instances (possibly typed as specific interfaces), a much simpler API would be some way to scan up the ancestor hierarchy and obtain the first one matching a type specifier.

For example, a TextBox could call GetClosestOfType<IValidationGroup>().RegisterValidator(this.Validations). There are various places we could put the implementation for GetClosestOfType, but let's not worry about that detail yet.

Pros:

  • Very easy to read static values (GetClosestOfType<IThemeProvider>().CurrentTheme)
  • Good use of .NET types to let developers either access components directly by concrete type, or for more cleanliness, declare an interface that exposes only APIs relevant to descendants.

Cons:

  • Doesn't directly help with accessing values that change. You'd need the provider to expose an event, subscribe to it and call your own StateHasChanged, and remember to unsubscribe during disposal.
    • Basically, it's not an inherent part of the rendering cycle, so it has many of the same drawbacks as React's deprecated context.
  • Also doesn't help with discovery of descendants. Requires same approach as for TreeContext above.
  • Would only work in a unit test if you have some wider test harness that lets you mock the GetClosestOfType result.

A possible mitigation to the "values that change" complexity would be exposing some method on the BlazorComponent base class called Subscribe(EventEmitter<T> eventEmitter, Action<T> onEvent). This would take care of calling StateHasChanged after each onEvent, and unsubscribing during disposal. Of course, that forces us to define the EventEmitter<T> concept.

3. Deep parameters

If we learn some of the lessons from React's deprecated context and newer Provider/Consumer, we might decide that our solution should be an inherent part of the render cycle. That is, providers change the value by rendering themselves with new values, and it triggers the same refresh on consumers that is triggered when their parameters change, so there are no new "change" APIs to learn.

Our APIs can be simpler because .NET objects have true runtime types, so we could do the value matching based on that type.

For example, on the provider side, we could declare a similar but simplified API using a new built-in component called Provider:

    <Provider Value=themeInfo>
       ... other markup and components go here ...
    </Provider>

    @functions {
        ThemeInfo themeInfo = new ThemeInfo
        {
            ThemeName = "retro"
        };
    }

Then, to receive such a value, we could make it part of Blazor's "parameters" system. Example:

The current theme is @CurrentTheme.ThemeName

@functions {
    [Parameter(Deep = true)] ThemeInfo CurrentTheme { get; set; }
}

By declaring Deep = true, the consumer is making the parameter into a special one that does not necessarily come from the direct parent, but rather from the closest <Provider>-given value that matches the declared type. Alternatively we could use different terminology, e.g., [DeepParameter] or [HierarchyParameter] or [Parameter(FromHierarchy = true)].

Whenever the original producer re-renders itself, this triggers the exact same child rerendering logic that normally happens for components passing parameters to direct children. The consumer doesn't have to subscribe or unsubscribe - it's the same as normal parameter updates, hitting the same lifecycle methods.

The framework takes on the burden of keeping track of the provider-consumer subscriptions instead of the developer.

Passing component instances

If a component wants to make its public API surface accessible to descendants, it just needs to do:

  <Provider Value=this>
      ...
  </Provider>

... then the descendant declares (in the Form example):

[Parameter(Deep = true)] Form ContainerForm { get; set; }

Optional naming

In case developers prefer to pass plain string values, or want to reuse the same type for distinct values, we could allow:

<Provider Name="LoggedInUser" Value="CurrentUser">
</Provider>

... received as:

[Parameter(Deep = true, Name="LoggedInUser")] User TheUser { get; set; }

It's unfortunate we can't use object-valued symbols to ensure there are no string clashes, but of course attribute values must be compile-time constants. I don't feel interested in having more complex APIs to supply the name at runtime. Hopefully in most cases the type alone is sufficient, given that the consumer has to specify that anyway.

Interfaces

Presumably we'd match to the closest provider whose value is assignable to the recipient's type, e.g.,

[Parameter(Deep = true)] IValidationGroup ValidationGroup { get; set; }

... would match the closest provided value that is assignable to IValidationGroup.

Precedence and ambiguity

As specified above, there's always a well-defined "closest" match. But if we wanted <Provider> to be able to provide multiple values at once to avoid tedious nesting, we could do:

    <Provider Values="new[] { firstValue, secondValue }">
        ...
    </Provider>

In that case we have to specify that, for example, we prioritise the latter items in the array over the earlier ones, given that more than one may be of the same type or implement the same interface. This should be a rare edge case.

Summary

Pros:

  • Easy APIs on both the provider and consumer side
  • Inherently integrated into the rendering cycle - no new lifecycle events to learn.
  • Works great for the "supply static data" and "supply dynamically changing data" scenarios
  • In unit tests, it's no different than for testing passing parameters to children or receiving them from parents.

Cons:

  • Still doesn't help with discovery of descendants. Requires same approach as for TreeContext or GetClosestOfType above.

4. Direct access to parent and children

The most obvious solution of all is to follow the same path as WinForms/WebForms/etc and just expose the entire component hierarchy directly. Technically, developers could implement their own versions of the above three if we let them walk this.Parent.Parent.Parent... or this.Children[2].Children[0]... etc.

They could obtain static values by walking up the tree until they find a component of the required type. They could manually subscribe to events on ancestors. They could discover all children of a given type, e.g., to associate all Tab instances with a TabSet.

The big drawback to this is that it falls instantly into the pit of unmaintainability that caused the React and Vue communities to reject such APIs. Unit testability is DOA, and antipatterns around relying on exact descendant hierarchy shapes will abound. And it still doesn't help with propagating updates - people will next need the equivalent of DOMMutationObserver.

In conclusion, no.

5. Discovery of children through RenderFragment inspection

Consider again the case where a parent component wants to see all its immediate children of a certain kind. Example:

    <TabSet>
        <Tab Title="Home">...</Tab>
        <Tab Title="About">...</Tab>
        @if (showSecretTab)
        {
            <Tab Title="Secrets">...</Tab>
        }
    </TabSet>

The TabSet will want to know the list of Title values so it knows what tab buttons to render. Equivalent scenarios occur with forms and validators, or even a Router if configured in the react-router style with <Route Url=... Component=... /> children.

None of solutions 1-3 directly help with this, and the solution I've described above is:

  • TabSet makes some RegisterTab callback available to descendants using TreeContext or GetClosestOfType<TabSet>() or <Provider>.
  • Tab contains OnInit logic that calls RegisterTab(this).

This is OK, but there's complexity to do with rendering order. The tabs won't be registered until after they all render, which in turn is after the TabSet has finished rendering.

If the TabSet wants to render the tab names, this forces it into a 2-phase render process:

  • On the first render, it collects the list of children (by a series of calls to RegisterTab).
  • Each RegisterTab causes the TabSet to call StateHasChanged on itself. This will queue a single re-render of the TabSet after all the existing queued entries (i.e., after all the Tab children). In this second render, TabSet can display the tab titles.
    • You have to hope this doesn't cause an infinite loop. In practice it doesn't when implemented in the obvious way (call RegisterTab from OnInit), but there are some ways it can cause an infinite loop (call RegisterTab from OnParametersSet).
    • Also, child components must remember to unregister themselves during disposal, otherwise this can leak memory or leave the parent thinking it still has the removed child.

While this approach is viable for advanced users (component library authors), it's far from ideal: perf is compromised (2 renders where 1 should suffice), it's complicated, and there's the possiblity of getting it wrong.

The solution in React is good. The this.children value passed to components isn't an opaque render function like it is in Blazor. Instead, it's an array of ReactElement instances, each of which describes a child that is going to be instantiated. So instead of just emitting this.children as part of render output, a React component can read data from this.children, and use that to build up a new set of children, possibly with additional props. The tabset would just read the list of tab title props from this.children without having to render them first.

In Blazor, we pass children as a RenderFragment, which is akin to a render function prop in React (a pattern that is gaining popularity in React-world). The drawback is that RenderFragment is opaque.

If we wanted, we could add the ability to render a RenderFragment outside the normal render cycle, into a temporary buffer. Then, the developer could inspect the render tree frames to read out the parameters being passed (e.g., the tab titles), and then to avoid re-rendering, could use the data in their own render logic, e.g.,

@foreach (var tabTitle in tabTitles)
{
    <span class='tab-title'>@tabTitle</span>
}

@ChildContent

@functions
{
    [Parameter] RenderFragment ChildContent { get; set; }

    private IEnumerable<string> tabTitles;

    protected override void OnParametersSet()
    {
        PrerenderedBuffer prerenderedChildren = ChildContent.RenderToBuffer();

        tabTitles = prerenderedChildren
          .Where(child => child.ComponentType == typeof(Tab))
          .Select(child => child.Parameter["Title"]);
    }
}

This avoids the 2-phase render completely, but does open up other questions:

  • How are the PrerenderedBuffer instances pooled? We don't want to allocate new arrays every time.
  • How will people understand that the PrerenderedBuffer entries are not actual component instances (because they come into existence only as part of real rendering), but rather are descriptors of what will be rendered?

Proposal

To enable a wide range of common high-level UI widget component types, I recommend we implement the "deep paramters" notion.

For discovery of child components, in the short term I suggest we see how far we can get using the manual "children call a registration method" approach, and leave open the option to implement RenderFragment.RenderToBuffer in the future. It might be that only advanced component library authors are doing child discovery anyway, and they can follow the correct pattern to make this work with 2-phase rendering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment