alpinejs / alpine

A rugged, minimal framework for composing JavaScript behavior in your markup.
https://alpinejs.dev
MIT License
28.03k stars 1.23k forks source link

Sharing state #254

Closed 7ammer closed 4 years ago

7ammer commented 4 years ago

If I have some javascript like so (note the shared state object):

const mySharedState = {
    show: false
};

const mainMenu = function(){
    return {
        state: mySharedState,
        isOpen() { return this.state.show === true },
        toggle() {
            this.state.show = !this.state.show;
            console.log(this.state.show);
        }
    }
};

Can someone help explain why something like this works:

<div x-data="mainMenu()">
  <button @click="toggle()" type="button">click me</button>

  <div x-show.transition="isOpen()">
    x test
  </div>
</div>

but this doesn't

<button x-data="mainMenu()" @click="toggle()" type="button">click me</button>

<!-- Somewhere else on the page -->
<div x-data="mainMenu()" x-show.transition="isOpen()">
  x test
</div>

Here is a working example: https://codepen.io/7ammer/pen/MWwOZVp

I've noticed that 'mySharedState' is shared but the second example is not reactive when the value changes.

7ammer commented 4 years ago

I guess I'm trying to follow this Vuejs pattern "Simple-State-Management-from-Scratch": https://vuejs.org/v2/guide/state-management.html#Simple-State-Management-from-Scratch

calebporzio commented 4 years ago

Yeah, that pattern does not work in Alpine.

I imagine Vue3 won't support that pattern (because of the switch to a Proxy-based reactivity core like Alpine).

Consider this example:

<script> const state = { count: 0 } </script>
<button x-data="{ shared: state }" @click="shared.count++" x-text="shared.count"></button>

<button x-data="{ shared: state }" @click="shared.count++" x-text="shared.count"></button>

Clicking one button doesn't change the other, but if you click the first one 3 times, then click the next button, the number will change from 0 to 4 instantly.

What's happening here is that the state IS actually shared, just mutations from one component don't trigger a refresh in the other.

There may be some simple way to enable this behavior.

Open to ideas.

Thanks!

bep commented 4 years ago

Open to ideas.

I'm deep into the "I should probably not comment on stuff I don't know too much about" land here, but:

<script> const state = { count: 0 } </script>
<button x-data="{ shared: state }" @click="shared.count++" x-text="shared.count"></button>

<button x-data="{ shared: state }" @click="shared.count++" x-text="shared.count"></button>
yatagasaru commented 4 years ago

There may be some simple way to enable this behavior. Open to ideas.

I have a workaround like this : https://codepen.io/fergushaga/pen/MWwrgJQ

  <div x-data="count()" x-trigger="inc-count">
    <button x-text="shared.count" x-on:click="incCount()"></button>
  </div>

  <div x-data="count()" x-trigger="inc-count">
    <button  x-text="shared.count" x-on:click="incCount()"></button>
  </div>

  <script> 
    const count1 = new AlpineData('inc-count', 'setCount')
    const state = { count: 0 }  

    function count(){
      return{
        shared: state,
        setCount(){
          this.shared = count1.get()
        },
        incCount(){
          state.count++
          count1.set(state)
        }
      }
    }
  </script>

Originally I built this helper class for my existing project that use bootstrap 4 modal. I want to pass data from outside x-data into the modal that has a x-for and trigger the reactivity everytime the modal opened.

ryangjchandler commented 4 years ago

I'm not sure this is the correct use case for Alpine. I believe the correct use case would be building the model component itself with Alpine and encapsulating all of the logic there. Again, Alpine is designed to be sprinkled into your existing markup that is JavaScript free, whereas Bootstrap is controlling the model JavaScript in your case.

yatagasaru commented 4 years ago

@ryangjchandler yes I'm aware of that, but consider this case :

I have a table generated by server. Inside the table there is a link and open a modal containg the detail of clicked item by fetching data from server, process it, and render it. Even if I can build the modal myself, how can I achieve reactivity / re-render elements inside the modal everytime i click the link. Untitled

That's the original idea of my helper class. If there is a cleaner and preferred way to do this with alpine, I would love to implement it

HugoDF commented 4 years ago

In this case, you probably want 2 different alpine.js components.

1 for the link (with 1 instance per rendered line I guess)

1 for the modal

The link component can dispatch a custom event that the modal can listen for.

The custom event contains all the data the modal needs to render.

Would that work?

yatagasaru commented 4 years ago

Unless I'm missing something, yes that would work if :

The data is already available before I click the link / before dispatching

$dispatch can await async data fetch

dispatching custom event can be programmatically triggered.

HugoDF commented 4 years ago

1 and 3 are true

1 Is true since you're inlining data in the template 3 Is true because Alpine.js provides $dispatch in both methods and the template

What's with 2? Can you fetch the async data and then trigger the event? Or trigger a "open" even then a "data-ready" event?

samadadi commented 4 years ago

<div x-data="{a: 'a'}"> <div x-data="{b: 'b'}"> <p x-text="a">character</p> </div> </div> As I know Alpine creates a component for every element that contains x-data attribute but Why I can not access parent component state from child component something like above code!!?

HugoDF commented 4 years ago

The data lives on the component instance in which it's set, the child component doesn't have the parent component in scope.

yatagasaru commented 4 years ago

Can you fetch the async data and then trigger the event?

I can't, because like I said before, my data isn't ready. I need to fetch it from server. Is there a way to do that?

<a x-on:click="$dispatch('item-details', fetchData())">item</a>

This will return undefined

HugoDF commented 4 years ago

Right, in this case, you could actually do

<a x-on:click="fetchData().then((info) => $dispatch('item-details', info))">item</a>
ryangjchandler commented 4 years ago

I've got a PR prepared for some internal events that get fired too. For example, after the data has been loaded and observation has start, as well as events fire after data updates and mutations.

yatagasaru commented 4 years ago

Right, in this case, you could actually do

<a x-on:click="fetchData().then((info) => $dispatch('item-details', info))">item</a>

yes, this is what I mean, I have to change a lot of things in my fetchData function to accommodate this.

It would be nice if $dispatch can await async, or I can execute dispatch from my fetchData. Just hoping tho, nevermind it. Actually alpine has made my life so much easier :)

HugoDF commented 4 years ago

I don't think dispatch being promise-aware is a core thing Alpine should do since there's a userland workaround.

rhengles commented 4 years ago

I didn't see anyone here mentioning x-init, i'm using it to capture the reactive object created by Alpine:

<div x-data="myData()" x-init="myInit()"></div>
var myObject;
function myData() {
  return {
    property: 'value'
  };
}
function myInit() {
  myObject = this; // here you get the reference and you can save it
}
ockam commented 4 years ago

@rhengles can you explain how you would use the oobject provided by x-init to share the state?

ryangjchandler commented 4 years ago

@ockam - since the x-init hook has access to the $data variable that Alpine uses to proxy any changes to data, you could store it in another variable and modify it directly. JavaScript passes objects and variables by memory address / reference, so any changes affect the original point in memory.

ockam commented 4 years ago

@ryangjchandler thanks for the precision.

Here’s what I came up with (note that this example use a bundler):

import 'alpinejs'

/* modal management */
window.modalState
window.modal = function() {
  return {
    show: false,
    isModalOpen() { return this.show === true },
    openModal() {
      this.show = true
      const body = document.body
      body.style.position = 'fixed'
      body.style.top = 0
    },
    closeModal() {
      const body = document.body
      body.style.position = ''
      this.show = false
    },
    modalInit() {
      window.modalState = this
    }
  }
}
// used outside of modal scope to trigger it
window.remoteModal = function() {
  return {
    openModal() {
      window.modalState.show = true
    }
  }
}
ryangjchandler commented 4 years ago

Yeah, does this work nicely? P.s. I'm working on a global state management layer for Alpine at the moment that weighs in at less than 1kb, my GitHub Sponsors can get early access. I'll make it OS once I've reached 10 subscribers ;)

ockam commented 4 years ago

Works perfectly.

I wonder if there’s a performance penalty related to the amount of content of your scope?

That could have been solved simply by putting my modal state higher up in the DOM, but I have a feeling this is not a good idea. Maybe I’m wrong...

HugoDF commented 4 years ago

Works perfectly.

I wonder if there’s a performance penalty related to the amount of content of your scope?

That could have been solved simply by putting my modal state higher up in the DOM, but I have a feeling this is not a good idea. Maybe I’m wrong...

Alpine walks the whole DOM below the element with x-data on render so yeah there is a performance impact.

Current state of state management in Alpine, you can give a go with the answers here or @ryangjchandler 's Spruce, I also believe reactive stores might make it into Alpine v3.

HugoDF commented 4 years ago

Closing this since it's not going to be in scope for v2 and there's a workaround: use https://github.com/ryangjchandler/spruce

Feel free to reopen or start a discussion 😄

olavoasantos commented 4 years ago

I've been playing with a composable architecture for handling global state and created a little PoC with Alpine. It's still early stages and I'm still getting my head around the best approach for structure and organization... But I got an MVP working. If you want to see the full thing check out this CodeSandbox, but the gist of it is:

const todos = state([]);

const addTodo = mutator(todos, (list, todo) => list.concat([todo]));

const completed = computed(todos, list =>
  list.filter(todo => todo.completed)
);

window.$stores = initStores({
  completed
});

On the Alpine side:

<div x-data="{}" x-store="completed" x-show="$completed.length > 0">
  <h2>Complete</h2>
  <ul>
    <template x-for="todo in $completed">
      <li x-text="todo.name" style="text-decoration: line-through"></li>
    </template>
  </ul>
</div>
ryangjchandler commented 4 years ago

@olavoasantos if you're interested, you should check out Spruce - it's a global state management library for Alpine. https://github.com/ryangjchandler/spruce

olavoasantos commented 4 years ago

@ryangjchandler I did take a look! Great job.

I actually learn by doing stuff haha I made this little state lib which is framework agnostic. Now I'm playing around trying to integrate it with different frameworks to see how far I can take it.

ryangjchandler commented 4 years ago

@ryangjchandler I did take a look! Great job.

I actually learn by doing stuff haha I made this little state lib which is framework agnostic. Now I'm playing around trying to integrate it with different frameworks to see how far I can take it.

Nice work - I like the approach, very functional. My aim with Spruce was to make it almost like an Alpine component, where you can mix and match data properties and functions within the state objects, but your approach which is similar to VueX definitely works for some!

Random72IsTaken commented 2 years ago

Things are now done with Alpine.store() and $store, I guess. :)