@Component({
selector: 'app-root',
template: '<app-parent [familyName]="familyNameValue"></app-parent>'
})
export class AppComponent {
familyNameValue = 'The Angulars';
}
@Component({
selector: 'app-parent',
template: '<app-child [familyName]="familyName"></app-child>'
})
export class ParentComponent {
@Input() familyName: string;
}
@Component({
selector: 'app-child',
template: '<app-grandchild [familyName]="familyName"></app-grandchild>'
})
export class ChildComponent {
@Input() familyName: string;
}
@Component({
selector: 'app-grandchild',
template: 'Family Name: {{familyName}}'
})
export class GrandchildComponent {
@Input() familyName: string;
}
This pattern forces every intermediate component to declare and forward the same input, which in React terminology is known as Prop Drilling.
Returning to the definition:
Now let’s examine how Context handles the same scenario.Context provides a way to pass data through the component tree without having to pass
propsInputs down manually at every level.
The Solution
Note: The implementation details will be explained shortly. For now, continue reading.
Imagine removing all those inputs and replacing them with a single generic one that any component in the tree can access directly, like this:@Component({
selector: 'app-root',
template: `
<context name="FamilyContext">
<provider name="FamilyContext" [value]="familyNameValue"> // This part
<app-grandchild> </app-grandchild>
</provider>
</context>
`
})
export class AppComponent { }
And for the component that actually needs the value:
@Component({
selector: 'app-grandchild',
template: `
<consumer name="FamilyContext">
<ng-template let-value>
Family Name: {{value}}
</ng-template>
</consumer>
`
})
export class GrandchildComponent { }
While this strategy appears functional, it likely won’t win universal approval. My initial instinct was to lean on sandboxing, which may explain why there’s no direct equivalent to React Context API in Angular. Still, you can view this as another route to the same destination.
By now, the purpose of the Context API should be clear. Let’s move on to how it functions.
How Does React Context API Work
Caution: I’ll be using React components here 😏.
The Context API revolves around two primary components: Provider and Consumer. The Provider is responsible for supplying a value to all descendant components that opt in as consumers. A single provider can serve multiple consumers and can also nest other providers beneath it. The Consumer, as you might expect, retrieves the value from the Provider. React traverses the component tree upwards from the Consumer to locate the closest Provider and passes its value to the Consumer via a callback. If no matching provider exists, a predefined default value is used. Whenever an ancestor Provider updates its value, the Consumer triggers a re-render. To establish a context, you invokecreateContext, optionally passing a default value. The function returns a context object that carries both Provider and Consumer components attached to it:
const MyContext = React.createContext('defaultValue');
The provider exposes a value prop that gets forwarded to the consumers:
function App() {
return (
<MyContext.Provider value="valueToBeConsumedByDescendantsConsumer">
<ComponentThatHaveConsumerAsChild />
</MyContext.Provider>
);
}
The consumer accepts a function that receives the Provider’s value as an argument. That function is invoked again (causing a re-render 🙃) each time the Provider’s value changes:
function ComponentThatHaveConsumerAsChild() {
return (
<MyContext.Consumer>
{(value) => (<h1>{value}</h1>)}
</MyContext.Consumer>
);
}
It’s worth mentioning that this isn’t the sole approach to consuming context — there are also contextType and useContext. I won’t delve into those since they’re tied to React’s specific patterns.
If the overall picture isn’t fully clear yet, the official documentation might offer additional clarity.
That’s enough discussion about React. Let’s start writing some actual code.
Angular implementation
Angular operates differently, so our approach will also differ, though the underlying concept and objectives remain the same.
From the beginning of this article series, we introduced three components:
contextproviderconsumer
and we used them in this manner:
@Component({
selector: 'app-root',
template: `
<context name="FamilyContext"> // (1) -----> The Context Component
<provider name="FamilyContext" [value]="familyNameValue"> // (2) -----> The Provider Component
<app-parent> </app-parent>
</provider>
</context>
`
})
export class AppComponent { }
@Component({
selector: 'app-grandchild',
template: `
<consumer name="FamilyContext"> // (3) -----> The Consumer Component
<ng-template let-value>
Family Name: {{value}}
</ng-template>
</consumer>
`
})
export class GrandchildComponent { }
Let's examine each component in depth shortly.
Utility function for strict mode people 😅
export function assertNotNullOrUndefined<T>(value: T, debugLabel: string): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`${ debugLabel } is undefined or null.`);
}
}
export function assertStringIsNotEmpty(value: any, debugLabel: string): asserts value is string {
if (typeof value !== 'string') {
throw new Error(`${ debugLabel } is not string`);
}
if (value.trim() === '') {
throw new Error(`${ debugLabel } cannot be empty`);
}
}
The Context Component
This component is responsible for defining a scope for both providers and consumers. A provider must reside within its context, and the same restriction applies to consumers.
Unlike the React Context API, we have no direct reference to a context object. To guarantee the connections between providers, consumers, and their context, we must assign a name to the context and its associated components.
Having a name enables us to:
- Operate multiple distinct contexts simultaneously without any conflicts.
- Allow the provider and consumer components to quickly locate their corresponding context by name.
- Verify that a provider and a consumer are correctly positioned beneath their intended context.
- Avoid the creation of duplicate contexts.
Another aspect of the context component is the defaultValue. As mentioned earlier in this article, if no provider is found for a context, a default value will be used.
In the image above, Consumer ( A ) will receive the value from the Context since there's no provider above it. Consumer ( B ), in contrast, will get its value from Provider ( 1 ).
Initial Implementation
@Component({
selector: 'context',
template: '<ng-content></ng-content>' // ----> (1)
})
export class ContextComponent implements OnInit, OnChanges {
@Input() name!: string; // ----> (2)
@Input() defaultValue?: any; // ----> (3)
constructor() { }
ngOnInit(): void {
assertStringIsNotEmpty(this.name, 'Context name'); // ----> (4)
}
ngOnChanges(changes: SimpleChanges): void {
const nameChange = changes.name;
if (nameChange && !nameChange.isFirstChange()) {
const { currentValue, previousValue } = nameChange;
throw new Error(`Context name can be initialized only once.\n Original name ${ previousValue }\n New name ${ currentValue }`);
}
}
}
- ng-content is used to project the content as it is.
- The context's name, for the reasons outlined above 😁.
- A
valuepassed to consumer components in scenarios where a provider for this context is absent. - Enforces that the context name is a non-empty string. The same validation will be applied to the other components.
- The name is immutable to align with the React methodology; however, you are free to adjust this in your own implementation. This validation applies to the other components as well.
The Provider Component
This component is tasked with passing its value down to the consumers, so it requires an input for that value. A context can have zero or more providers. Each consumer will use the value from the closest provider above it.
Looking at the image, Consumer ( A ) will receive the value from the Context. Meanwhile, Consumer ( B ), Consumer ( C ), and Consumer ( E ) will be provided the value from Provider ( 1 ).
For Consumer ( D ), the value of Provider ( 2 ) is used because it is the nearest provider in the hierarchy.
Initial Implementation
@Component({
selector: 'provider',
template: '<ng-content></ng-content>'
})
export class ProviderComponent implements OnInit {
@Input() name!: string; // ----> (1)
@Input() value?: any; // ----> (2)
ngOnInit(): void {
assertStringIsNotEmpty(this.name, 'Provider context name');
if (this.value === undefined) { // ----> (3)
throw new Error(`Provider without value is worthless.`);
}
}
ngOnChanges(changes: SimpleChanges): void {
const nameChange = changes.name;
if (nameChange && !nameChange.isFirstChange()) {
const { currentValue, previousValue } = nameChange;
throw new Error(`Context name can be initialized only once.\n Original name ${ previousValue }\n New name ${ currentValue }`);
}
}
}
- The context's name, needed to establish which context this provider is associated with.
- A
valueset by the provider for the consumer components. - A provider is only useful when it carries a value. If it has none, there's no reason to keep it; consumers fall back to another provider or the default value established by the context.
The Consumer Component
This component ultimately holds the value from its nearest provider, or the default context value when no provider exists above it in the tree.
Before diving into its details, let's look at how it will be used.
@Component({
selector: 'app-grandchild',
template: `
<consumer name="FamilyContext">
<ng-template let-value>
Family Name: {{value}}
</ng-template>
</consumer>
`
})
export class GrandchildComponent { }
An ng-template is utilized as a flexible means to supply either the nearest provider's value or the context's defaultValue via a template variable, let-value. It also gives us more control over the change detection process, which we'll explore further down.
Initial Implementation
@Component({
selector: 'consumer',
template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit {
@Input() name!: string; // ----> (1)
@ContentChild(TemplateRef, { static: true }) templateRef!: TemplateRef<any>; // ----> (2)
ngOnInit(): void {
assertStringIsNotEmpty(this.name, 'Consumer context name');
if (this.templateRef === undefined) { // ----> (3)
throw new Error(`
Cannot find <ng-template>, you may forget to put the content in <ng-template>.
If you do not want to put the content in context then no point in using it.
`);
}
}
ngOnChanges(changes: SimpleChanges): void {
const nameChange = changes.name;
if (nameChange && !nameChange.isFirstChange()) {
const { currentValue, previousValue } = nameChange;
throw new Error(`Context name can be initialized only once.\n Original name ${ previousValue }\n New name ${ currentValue }`);
}
}
}
- The context's name, essential for determining the context it's tied to.
- The template reference, with
static: true, is required to access it withinngOnInit. - The
ng-templateis required. There's no point in using a consumer if you're not going to consume its value.
RECAP: currently, our code only performs input validation.
The subsequent step is to enforce that provider and consumer components reference the correct context.
I assume you're familiar with Dependency Injection and its resolution process. In essence, you request a dependency, and Angular searches for it across multiple injectors. If it's not found, an error is logged to the browser console 😁.
Understanding this resolution mechanism is key to following the rest of the code. Our validation and value resolution logic will rely on it. Essentially, we'll link each component type to the one directly above it, forming a chain where each component holds its parent reference, and the topmost component (root of the tree) points to null. It's similar to the Prototype Chain 😁. This concept is illustrated in the next image.
Context Validation
We enforce two primary rules:
- Context names must be unique — you cannot create multiple contexts with identical names.
- Both providers and consumers must be associated with a context.
First, we'll introduce a method in ContextComponent to prevent creating a second context with the same name.
@Component({
selector: 'context',
template: '<ng-content></ng-content>',
})
export class ContextComponent implements OnInit {
@Input() defaultValue?: any;
@Input() name!: string;
constructor(
@Optional() @SkipSelf() public parentContext: ContextComponent | null // ----> (1)
) { }
ngOnInit(): void {
assertStringIsNotEmpty(this.name, 'Context name');
this.ensureContextUniqueness(this.name); // ----> (2)
}
... code omitted for brevity
public getContext(contextName: string) { // ----> (3)
let context: ContextComponent | null = this;
while (context !== null) {
if (context.name === contextName) {
return context;
}
context = context.parentContext;
}
return undefined;
}
public ensureContextUniqueness(contextName: string) { // ----> (4)
let context: ContextComponent | null = this.parentContext;
while (context !== null) {
if (context.name === contextName) {
throw new Error(`Context ${ this.name } already exist.`);
}
context = context.parentContext;
}
}
}
- We inject the parent context component 😲 refer to the above image.
@Optional() handles the possibility that this context is the first one in the tree, where no parent would be found.
@SkipSelf() instructs the DI mechanism to skip the current component's injector and start searching from the parent injector. This is necessary since the current context is already what we have.
- This checks for an existing context with the same name and throws an error if it finds one.
- This function locates a context by name, starting with the current context. It checks if the name matches the parameter; if not, it repeats the process with the parent context. Ultimately, it returns
undefinedif no matching context is found. This method will be useful for other components later. - Similar to point 3, but this starts the search with the parent context, not the current one.
Second, we will update ProviderComponent to fetch its associated context and confirm it exists.
@Component({
selector: 'provider',
template: '<ng-content></ng-content>'
})
export class ProviderComponent implements OnInit {
@Input() name!: string;
@Input() value?: any;
private providerContext!: ContextComponent;
constructor(
@Optional() private context: ContextComponent | null, // ----> (1)
) { }
ngOnInit(): void {
... code omitted for brevity
if (this.context === null) { // ----> (2)
throw new Error(
'Non of provider ancestors is a context component,
ensure you are using the provider as a context descendant.'
);
}
this.providerContext = this.context.getContext(this.name); // ----> (3)
assertNotNullOrUndefined(this.providerContext, `Provider context ${this.name}`); // ----> (4)
}
public getProvider(contextName: string) { // ----> (5)
let provider: ProviderComponent | null = this;
while (provider !== null) {
if (provider.name === contextName) {
return provider;
}
provider = provider.parentProvider;
}
return undefined;
}
}
- We inject the
ContextComponent. Angular's DI will provide the closest context component instance. This is then used as the starting point to search for other contexts further up the tree. - This safety check ensures a context exists before we search for our specific one. It's a quick way to identify missing context wrappers.
- We retrieve the correct context for this provider and store it in its instance.
- We verify the provider does indeed have a context assigned to it.
- We locate a provider by context name. Starting with the current provider, we check its context name against the argument. If they don't match, we repeat the process with the parent provider. If the search finishes without finding a provider, returning
undefinedis acceptable since having a provider is optional for a context. This method will be essential for the consumer component.
Third, we'll adjust the ConsumerComponent so it can find its context and provider, verifying its context's existence.
@Component({
selector: 'consumer',
template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit {
@Input() name!: string;
@ContentChild(TemplateRef, { static: true }) templateRef!: TemplateRef<any>;
private consumerContext!: ContextComponent;
private consumerProvider?: ProviderComponent;
constructor(
@Optional() private context: ContextComponent // ----> (1)
) { }
ngOnInit(): void {
... code omitted for brevity
if (this.context === null) { // ----> (2)
throw new Error(
'Non of consumer ancestors is a context component,
ensure you are using the consumer as a context descendant.'
);
}
this.consumerContext = this.context.getContext(this.name); // ----> (3)
this.consumerProvider = this.provider?.getProvider?.(this.name); // ----> (4)
assertNotNullOrUndefined(this.consumerContext, `Consumer context ${this.name}`); // ----> (5)
}
}
- We inject the
ContextComponent. Angular will resolve the nearest context and provide it to us. - As before, we check for the presence of a context before anything else for immediate feedback.
- We retrieve and store the consumer's associated context.
- We confirm that the context is defined.
- We fetch the nearest provider for this consumer and store it. This is used later to observe changes in the provider's value.
RECAP: The code currently validates inputs, confirms a single unique and correct context, and provides clear guidance to the developer on how to use the context API.
Now, we move on to the crucial part: obtaining the value from the context and the nearest provider for the consumer.
Providing the value
Remember from the start of the article:
The Consumer will re-render whenever a Provider ancestor value changes.
This means the embedded ng-template needs to be updated dynamically, not just created once.
Delivering this value might appear straightforward — you just build the ng-template and bind a value. That's true in principle, but there are additional considerations regarding Angular Change Detection. For instance, updating the template within a component using the OnPush strategy differs from one using the Default strategy. We'll get into more detail in a later section.
For constructing the template, we have ViewContainerRef, which creates and hosts the ng-template. It also gives us a reference we can use to update the template's value. You can find more examples and info here.
@Component({
selector: 'consumer',
template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit, OnDestroy {
... code omitted for brevity
private buildTemplate(initialValue: any) { // ----> (1)
this.embeddedView = this.viewContainerRef.createEmbeddedView(this.templateRef, {
$implicit: initialValue
});
}
private updateTemplate(newValue: string) { // ----> (2)
this.embeddedView!.context = {
$implicit: newValue
};
this.embeddedView?.markForCheck();
}
private render(value: any) { // ----> (3)
if (this.embeddedView) {
this.updateTemplate(value);
} else {
this.buildTemplate(value);
}
}
}
- We create the template with the initial value (which is either the context's default value or its nearest provider's current value) and store the returned
ng-templatereference for subsequent updates. - We refresh the template's value, which is the
let-valuevariable, and mark the template to be checked during the next change detection cycle. - A wrapper method to update the template if it exists, or build it if it's new.
Regarding value changes, the typical lifecycle hook for observing @Input changes is OnChanges. However, since the value isn't passed directly as an input to the consumer component, this hook isn't applicable here.
The remedy is to use a ReplaySubject in the ProviderComponent to emit a new value whenever the provider's value input changes. The ConsumerComponent will subscribe to this subject to update its template accordingly.
@Component({
selector: 'provider',
template: '<ng-content></ng-content>'
})
export class ProviderComponent implements OnInit, OnDestroy {
private valueState = new ReplaySubject<any>(1); // ----> (1)
ngOnChanges(changes: SimpleChanges): void { // ----> (2)
const valueChange = changes.value;
if (valueChange) {
this.brodcaseValueChanges(valueChange.currentValue);
}
}
... code omitted for brevity
private brodcaseValueChanges(newValue: any) {
this.valueState.next(newValue);
}
public valueChanges() { // ----> (3)
return this.valueState.asObservable();
}
ngOnDestroy(): void {
this.valueState.complete(); // ----> (4)
}
}
- We initialize the
ReplaySubjectwith a buffer size of 1. This ensures any new consumer can always access the provider's most recent value immediately upon subscription. - We repurpose the existing
ngOnChangeslifecycle (which we previously used for name validation) to detect changes in the provider'svalueinput. - We expose the
ReplaySubjectas an observable for the consumers to subscribe to. - In the
ProviderComponent'sOnDestroyhook, we complete theReplaySubjectto clean up resources.
Now for the ConsumerComponent part:
@Component({
selector: 'consumer',
template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit, OnDestroy {
private providerValueChangesSubscription?: Subscription; // ----> (1)
ngOnInit(): void {
if (this.consumerProvider) { // ----> (2)
this.providerValueChangesSubscription = this.consumerProvider
.valueChanges()
.subscribe((providerValue) => {
this.render(providerValue); // ----> (3)
});
} else { // ----> (4)
this.render(this.consumerContext.defaultValue);
}
}
... code omitted for brevity
ngOnDestroy(): void {
this.providerValueChangesSubscription?.unsubscribe(); // ----> (5)
}
}
- A field to store the subscription to the provider's observable, enabling us to unsubscribe when the consumer is destroyed.
- We check if a provider exists before subscribing to its value stream.
- If a provider is found, the consumer re-renders whenever the provider emits a new value.
- If no provider is found, the consumer renders only a single time with the context's default value.
- We unsubscribe from the provider's
ReplaySubjectwhen the consumer component is destroyed to prevent memory leaks.
Fantastic, you've made it to this point! 🎉, you now have a functioning React Context within Angular. Let's now look at the idiomatic Angular approach to sharing data across the component tree.
The Angular Approach
Angular ships with a built-in Dependency Injection system that offers several patterns for scenarios where something similar to the React Context API might be required.
In the "The Problem" section, it was demonstrated that propagating a value down to child components typically necessitates an @Input binding on every intermediate component, even if those components simply wrap another component. This requirement disappears, however, once you register an InjectionToken on the ancestor and inject that same token where needed within the tree.
Here’s what happens when the root component exposes the token:
const FamilyNameToken = new InjectionToken('FamilyName');
@Component({
selector: 'app-root',
template: `<app-grandchild> </app-grandchild>`,
providers: [{provide: FamilyNameToken, useValue: 'The Angulars'}]
})
export class AppComponent { }
And on the component that needs the value, inject the same token:
@Component({
selector: 'app-grandchild',
template: `Family Name: {{familyNameValue}}`
})
export class GrandchildComponent {
constructor(@Inject(FamilyNameToken) public familyNameValue: string) { }
}
At first glance this looks clean and straightforward, but there’s a subtle limitation: the value bound to the token is static. If you need to change it, a simple injection won’t cut it — Angular resolves and fixes that value once for GrandchildComponent. To make the data dynamic, you could either introduce an RxJS Subject or provide a class that holds the state.
class FamilyName {
private state = new ReplaySubject(1);
public setName(value: string) {
this.state.next(value);
}
public getName() {
return this.state.asObservable();
}
}
The root component injects the class provider and assigns the new value:
@Component({
selector: 'app-root',
template: `<app-grandchild> </app-grandchild>`,
providers: [FamilyName]
})
export class AppComponent {
constructor(public familyName: FamilyName) {
$familyNameState = this.familyName.setName('The Angulars');
}
}
Now the component that needs the value injects the FamilyName class and subscribes for updates.
@Component({
selector: 'app-grandchild',
template: `Family Name: {{$familyNameState|async}}`
})
export class GrandchildComponent {
$familyNameState = this.familyName.getName();
constructor(public familyName: FamilyName) { }
}
Additionally, you can re-register the FamilyName class at any component level to make that component act as the one responsible for providing the context.
All things considered, the ability to share values without cluttering every template with bindings can significantly cut down on boilerplate and the number of classes you need to write.
Example
To see the implementation in practice, I'll walk through a chat interface built with components that take advantage of this context.
Check out the working demo for the final result.
Chat Message Component
This component consumes the context to fetch the message.
@Component({
selector: 'app-chat-message',
template: `
<consumer name="ChatContext">
<ng-template let-value>
<h4>{{value.message}}</h4>
</ng-template>
</consumer>
`
})
export class ChatMessageComponent { }
Chat Avatar Component
This one also consumes the context to get the avatar. You'll notice the changeDetection setting has been switched to OnPush.
@Component({
selector: 'app-chat-avatar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<consumer name="ChatContext">
<ng-template let-value>
<img width="50" [src]="value.avatar">
</ng-template>
</consumer>
`
})
export class ColorAvatarComponent { }
Chat Container Component
This component brings the other pieces together, mainly to handle layout and styling. For the first chat message it relies on the provider from AppComponent, while a second message gets its own fresh provider.
@Component({
selector: 'app-chat-container',
template: `
<div style="display: flex;">
<app-chat-avatar></app-chat-avatar>
<app-chat-message></app-chat-message>
<provider name="ChatContext" [value]="{name:'Nested Provider Value'}">
<app-chat-message></app-chat-message>
</provider>
</div>
`
})
export class ChatContainerComponent { }
App Component
Here a context named ChatContext is created with no default value, and a provider supplies an initial chatItem. This same instance is shared with both ChatMessageComponent and ChatAvatarComponent.
Hitting the Change Chat Item button swaps out the chatItem reference, which in turn pushes the updated value to the consumers.
@Component({
selector: 'app-root',
template: `
<context name="ChatContext">
<provider [value]="chatItem" name="ChatContext">
<app-chat-container></app-chat-container>
</provider>
</context>
<button (click)="updateChatItem()">Change Chat Item</button>
`
})
export class AppComponent {
chatItem = {
message: 'Initial name',
avatar: 'https://icon-library.com/images/avatar-icon-images/avatar-icon-images-4.jpg',
}
updateChatItem() {
const randomInt = Math.round(Math.random() * 10);
this.chatItem = {
message: `Random ${ randomInt }`,
avatar: `https://icon-library.com/images/avatar-icon-images/avatar-icon-images-${ randomInt }.jpg`,
}
}
}
Bonus: The OnPush Problem
In the Angular Implementation section, a hurdle surfaced when the host component — the one wrapping the consumer — was set to use the OnPush change detection strategy. To handle that, the shared value was distributed via a ReplaySubject.
Here's the core issue: OnPush stops the automatic change detection checks, which means the template won't refresh unless one of these scenarios happens:
- The component gets a new
@Inputreference. - An event handler inside the component gets fired.
- An observable connected to the template through the async pipe emits a new value.
None of those cases apply to ConsumerComponent, however.
- There is no
@Inputfor the value since it's bound indirectly. - No event handler or user interaction exists in the component.
- There’s no observable directly tied to the template because the projected content is passed through untouched.
Note: the term "template" here refers to the template property in the @Component decorator, not ng-template.
An earlier fix was to rely on the DoCheck lifecycle hook. This is a typical approach when you're working with OnPush and need to watch for changes in mutable data structures, then flag the component for a new round of change detection.
That said, DoCheck runs during every change detection cycle. With OnPush, though, the detector bypasses the component entirely, so the hook may not even run unless something triggers it manually. Even if it does, you still won't know whether the provider's value actually changed, which makes it an unreliable signal.
This was just a quick aside for anyone who might run into this down the road.
Summary
If state management libraries are new to you, this pattern may feel like a useful tool, since it addresses a similar concern to render propagation without a dedicated store.
And if you're coming from a React background, it gives you a taste of the Context API inside an Angular app — though Angular can naturally pull this off with nothing more than a solid grasp of its dependency injection system.
Adopting this kind of approach in your application adds real value, but it also means adapting to a fresh way of handling shared data.
Illustration was created with Excalidraw.



