alpine: Nested components cannot access external data
Hi @calebporzio, thanks for the amazing work so far. I was having a go with alpinejs and I noticed that, when there are nested components, the internal component cannot access the scope of the external one.
For example,
<html>
<head>
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v1.2.0/dist/alpine.js" defer></script>
</head>
<body>
<div x-data="{ foo: 'bar', foo2: 'BAR' }">
<span x-text="foo"></span>
<div x-data="{ foo: 'bob' }">
<span id="s1" x-text="foo"></span>
<span id="s2" x-text="foo2"></span>
</div>
</div>
</body>
</html>
I would expect span#s2
to display ‘BAR’ or, in alternative, i would expect to be able to reference foo2 in the internal data structure.
I’m happy to work on a PR for this but I just wanted to check with you first in case this behaviour is expected and you do not want components to access external scopes.
Thanks, Simone
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 23
- Comments: 63 (16 by maintainers)
Hey @SimoTod,
Great question.
This is definitely something that should be on the radar. Inter-component communication is a common need.
I suppose I’ve wanted to nail the core before adding this type of feature because it will increase the complexity of the project and therefore the issues, etc…
I would love to keep this conversation going though and start discussing potential APIs for this.
Here are a couple routes we could go off the top of my head: A) A “prop” system
or something like that B) Accessing the parent scope from a magic
$parent
object or something:C) Accessing data through simply passing down scope like you mentioned:
Let’s keep the conversation going. Thanks!
Just an FYI for those following, this is discussed towards the end of the Full Stack Radio podcast episode where Caleb discusses Alpine. Worth a listen: http://www.fullstackradio.com/132
Summary:
Worth a listen if you’re interested in this issue.
+1 on
$parent
, Vue is actually offering the same here, so devs that already know Vue likely know the concept: https://vuejs.org/v2/guide/components-edge-cases.html#Accessing-the-Parent-Component-InstanceGood points @calebporzio.
Option A feels similar to the approach React and other frameworks take but, if I didn’t have any experience, it would be an additional learning friction. It also forces a dev to pass down the variables if there are multiple nested levels, polluting the DOM. It probably makes easier to implement the “reactivity” part, though.
Option C feels more natural to me and it kinda match the scope rules in javascript but we wouldn’t be able to use variables from the parent scope if a variable in the current scope has the same name.
Option B requires dev to know about the parent rule, I feel it can be easily forgotten, leading to bugs.
My preference, without any tech consideration, would be C + support for the B syntax to access the parent scopes when names clash.
What are your thoughts?
Hi @torshakm
At the moment, communication between components follows a publisher / subscriber approach where a component dispatches an event and another component sets a listener.
Example 1 (separate components)
Since these 2 components are independent and events only travels up the DOM, they need to communicate through the global scope so listeners need to use the window modifier in order to work.
Example 2 (nested components)
In this case, evente will naturally bubbles up to the parent component so it will work without using the
window
modifier.Generic considerations
myevent
is the name of the event and you can use any name you think it’s appropriate, the important part is that you need to use the same name in your click handler. For example,$dispatch('foobar')
and@foobar.window
. The second argument of $dispatch is an object that you can retrieve via the detail property of the $event object in your listeners so it can carry any message you want to pass between componentsAbout tabs In regards of your specific request, I believe It can be designed in a different way. Your alpine component is, logically speaking, a tab group, not a single tab. A tab by itself doesn’t have any interactivity and only makes sense along other tabs. Inside a tab, you can still have nested components if needed. Following these considerations, a possible architecture for this use case would be
I hope it helps.
If x-data were interpreted in the context of the enclosing component, you could do
I think this makes a very natural “props” system that emerges organically from the existing Alpine design without adding any new attributes.
Leaving a couple of considerations for when you get back online: I’ve built a PoC and option C is a bit of a pain when updating a scope property from the child component: it could either create the property in the child scope or climb the hierarchy, updating the property when it finds a match but creating the property in the child scope if it does not find the property. None of these solution are really appealing and easy to understand. The $parent solution at this point seems more solid and would remove ambiguities.
I solved it like this:
Based on @KevinBatdorf 's solution
Just gonna throw a car on the idea train. The similarity to React Contexts @nyura123 mentioned sits well with me, since it’s attempting to solve the prop drilling that’s inevitable with any
props
approach.I’d suggest a different syntax, though, which would follow the
$ref
pattern Alpine already uses. Contexts would name themselves withx-context
and there would be a new magic property called$contexts
that would allow access to any named context in the node’s ancestry.One tradeoff I see with ☝️ is the implicit access all descendants have to any context above. That may be a win for simple cases; but if the goal is to get to reusable agnostic components, it feels dangerous to allow any descendant access to everything above it (especially if descendants can mutate values). The way React avoids this is that components have to explicitly “consume” a context. I think to Alpine, that syntax would probably look something like:
The consumption pattern definitely protects a some 3rd party descendant from accidentally, or maliciously, manipulating generically named stores.
Just a thought. Also, apologies if my syntax suggestion is way off base; I’ve only been experimenting with Alpine for a week or so. Loving it so far, though! Thanks for all the work!
@calebporzio Where are we with this one? Would you need any help making it happen? What about @SimoTod’s solution?
I have a for loop with components inside that have an open/close state so I need to set the x-data property for all of them individually while having access to the parent scope. I really need this so I’m willing to help.
I also came here from the podcast (and I really like what I’ve seen so far). If you want my 50 cents on this (very important issue):
I think one clue lives in “component composability” without each component having to know where it lives in the component tree. I’m having a hard time wrapping my head around constructs like
$parent.$parent.$parent.foo
in that sense.So for me, that leaves A (prop system) and C (passing down scope).
I love the simplicity of C, and I assume it should be possible if needed (in a future version) to somehow restrict the scope by some contract in the child component (aka props):
You have to include the helper’s script on your page.
Here’s a demo: https://codepen.io/KevinBatdorf/pen/ZEpMeJJ
Note that I used
$el.__x_for.c - 1
to get thex-for
index. I’m not sure how reliable that is. You may want to not use `x-for and instead implement the insertion manually.See https://github.com/alpinejs/alpine/issues/49#issuecomment-626251114 Alpine won’t implement it, at least not in v2. Basic cases can be implemented using the event pattern. For complex cases, there’s is a third party library called spruce.
It doesn’t read as nice as the other options. Hahaha, I’m never happy.
I think i can cope with a prop system, it would be good if I could have “transparent” components though.
I was more rigid than you, then 😂 My ideal implementation would be one that doesn’t force me to go through all the children to add an additional
$parent
if i decide to changeto
or force me to add the variables that I want to pass through in the intermediate component if it doesn’t use them.
I understand the point about having a sort of interface so we control what passes through so, after reading the last posts, I would also be happy if something like this worked:
I think Caleb is looking into options for v3.
For me “pluggable” in this context means that the “accordeon component” (or whatever) must not need to know about its surroundings to do its thing. It needs some data according to a contract/interface.
In that sense, all of A, B, and C above satifies that (even if the
$parent.foo
looks sucpicous) …I do, however, suspect that if we discuss this enough, we will eventually end up with the conclusion that Vue got it right.
The main problem with those approaches is that are quite frontend oriented. If your views are composed serverside (e.g. Blade component) you could sometimes have a generic component such as an accordion between your parent and your child. That would break the chain and since the accordion is generic and mayne used with a lot of other components you can’t have all the possible combinations in it. I think it’s what bep meant when he said pluggable, he wants to be able to compose other components at any time without breaking the ineritance.
I think that what @carlmjohnson proposed could work as long as x-data can resolve variables from any ancestor in the chain and not just the parent (not sure if you meant that).
P.s. About thr last snippet, it will be different in v3 but with v2 is better not to define components on the html or body tag because any time something changes, Alpine has to walk the component DOM so it’s not ideal performance wise.
I’m relatively new to this so please bear that in mind.
I like the idea of nested scopes (Option C above) - if feels familiar and natural - but wouldn’t there need to be some sort of barrier in the html which prevents further searches up the x-data/scope chain. I worry that without the barrier, it would be easy to create hard-to-find bugs. For example, I create an x-data component in a partial in my server-side template engine and I accidentally include a reference to an undefined variable. If I use the partial in several places, it may work as expected or may not, depending on the context in which it is used. With a barrier, we see the error every time.
Taking @SimoTod’s example above:
would become:
and would reliably log an error as the scope search stops at the x-barrier. (BTW I don’t like the name x-barrier - it’s just for illustration).
I don’t know if this is feasible or not in the Alpine codebase, it just feels like a more comfortable api.
@SimoTod in practice this is effectively the same as named scopes, since I could scope it like so:
I would be satisfied with this solution.
Change
openModal()
to take a second argument, which issomeParentVariable
, send that as part of the event, and change the event listener to get that value from the event.One advantage of explicit x-props (A) is that a child cannot modify the parent’s x-data, making for a top-down data flow (events/callbacks up, data down). I wouldn’t want a child component that I include somewhere down the tree changing my x-data. Also it wouldn’t be clear who “owns” the data/what is the source of truth – if a child changes x-data, it might not expect the data to revert to parent’s value on next render.
Another plus is that a component won’t behave differently based on where you place it because it would start seeing different ancestors’ scopes.
I continue to think this would be just as good if x-data were evaluated in the context of its parent, like this:
I don’t thinking adding x-props really adds anything in terms of clarity.
In fact, inherit the context when evaluating x-data would also solve another minor problem with Alpine. Today, all x-data functions must be defined on window, so that they can be looked up at initialization. With an inherited context for x-data, you could define one window function and put it on the root <html> and then all the other x-data could inherit it:
I really like this idea of having a contract for child component(making things a lot more explicit). Basically C but with some of the explicitness of A. Remind me of closure definition in Rust and other languages:
|val| val + x
whereval
is defined to be “captured” from the outer scope.This would also solve the hard to trace error that @stuartpullinger raised
There’s one use case that’s not covered by
$parent
. Imagine a simple Collapse component that toggles the visibility of it’s child:On first glance it may seem that
$parent
covers this use case, but what ends up happening is that the outer component’s functionality is tightly coupled to it’s HTML structure. If we ever decide that we would like to nest something inside the Collapse component this approach breaks.In Vue this is tackled via slots and scoped slots:
In Stimulus this is solved by controller namespacing:
Something close to Stimulus’s approach would be optimal, IMO. Along the lines of:
Yes. I think you nailed it.
I’d probably sum up my position as:
$parent
is sufficient for now!) …I’d leave your
$parent
for now until we have a decision from the boss.I guess implementing
$parent
now could affect any future implementation of scoping “magic”. So yeah, we’ll have to see if @calebporzio takes an interest in this idea and makes a call on it.It was AWESOME working this through with you - I really hope you get something merged in! 👏
Hi @rosswintle
Thanks for your feedback.
This is the way I implemented it:
$parent
refers the parent alpine component, not to the parent DOM item.Each component, unless it’s the root component, will have another
$parent
property to access the ‘grandparent’ scope and so on.I don’t think
$refs
would work because as far as I know you can only refer a child item. A component don’t have visibility of a reference defined on a parent DOM element unless we switch paradigm and we store a global list of refs somewhere but it doesn’t seem the right direction to take.This is how your code would look like if the PR goes through: https://codepen.io/SimoTod/pen/jOEZpKy?editors=1111
I initially tried to implement the “magic” inheritance. It’s technically possible but we need to deal with:
For these reasons, I think ‘$parent’ would be a nice compromise but I’m open to try other options.