Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

bwrigley's avatar

Complex recursive component structure and how to override reactive variable

Hi,

I have an app that I am probably making over-complex and certainly open to feedback of that nature :)

The Page that I'm working on displays topics, each of which can have n subtopics each of which can have n subtopics etc etc

When the Page first loads only top-level topics are shown and the user can click any topic to expand it and show the subtopics inside. And they can expand the subtopic to show its subtopics.

The user can also choose to create a subtopic in any topic/subtopic directly from this page.

The complication comes that when a new subcategory is created I want it shown on the page straight after creation, so essentially I want the parent category to expand to show the newly created topic.

I'm trying to do this by passing an array openTree from the controller that includes a list of topic ids that should render in an expanded state when the page loads/reloads. I 'provide' this array in my Page and 'inject' it in all the topic components to set the open/close state.

This all works fine, but then of course, the user can no longer expand/collapse topics as this is defined by the contents of the array! I'm going round in circles now.

I hope that makes some sense!

My Page:

<script setup>

    import { provide, ref, reactive } from 'vue';
    import Layout from '@/Shared/Layout.vue';
    import TopicBlock from '@/Shared/Topic/TopicBlock.vue'
    import TopicCreateForm from '@/Shared/TopicCreateForm.vue';


    const props = defineProps({
        topics: Array,
        openTree: {
            type:Array,
            default: []
        }
    });

    let showTopicForm = ref(false);
    let currentTopic = ref(null);

    function toggleTopicForm(){
        showTopicForm.value = ! showTopicForm.value;
        fade.value = ! fade.value;
    }

    provide('openTree', props.openTree);

    provide('toggleTopicForm', {
        toggleTopicForm,
        currentTopic
    });


</script>

<template>
    <Layout title="Topics Index" type="scrollable">

//...

        <div class="w-5/6 text-white  text-lg lg:text-xl">
            <TopicBlock :topics="topics" />
        </div>

    </layout>

    <TopicCreateForm v-if="showTopicForm"/>
</template>

The TopicBlock which shows all the subtopics of this topic

<script setup>

    import TopicBar from '@/Shared/Topic/TopicBar.vue'

    defineProps({
        topics: Array,
    });

</script>

<template>

        <div v-for="topic in topics" class="w-full pb-2" >
            <div class="shadow-sm shadow-gray-700">
                <TopicBar :topic="topic" />
            </div>
        </div>

</template>

The 'TopicBar' that shows the actual details of each topic/subtopic

<script setup>

import { computed, inject} from 'vue';
import ChevronDown from '@/Shared/SVG/ChevronDown.vue';
import TopicBlock from './TopicBlock.vue';
import TopicBarButtons from '@/Shared/Topic/TopicBarButtons.vue'; // Buttons for edit/delete/create

    const props = defineProps({
        topic: Object
    });

    const openTree = inject('openTree');

    let showChildren = openTree.includes(props.topic.id);

</script>

<template>

    <div class="rounded p-2 flex items-center relative" >

        <ChevronDown
                v-if="topic.children && topic.children.length > 0"
                width="20"
                height="20"
                @click="showChildren = !showChildren"
                :class='{"-rotate-90" : !showChildren}'
                class="fill-white absolute -left-5"
        />

        <div class="truncate w-32 rounded px-2 py-1 text-center" :class="topic.background">
            {{ topic.name }}
        </div>
        <!-- Description -->
        <div class="text-base text-left hidden lg:inline ml-3 truncate w-auto">
            {{ topic.description }}
        </div>

        <TopicBarButtons :topic="topic"/>

    </div>

    <div v-if="showChildren" class="relative ml-2 lg:ml-10">
        <div>
            <TopicBlock
                :topics="topic.children"
            />
        </div>
    </div>

</template>
0 likes
2 replies
LaryAI's avatar
Level 58

One solution to this problem is to create a separate reactive variable for each topic component that tracks its open/closed state. This way, the openTree array can be used to set the initial state of the components, but the user can still interact with them and change their state.

To implement this solution, you can modify the TopicBar component to use a reactive variable to track its open/closed state instead of relying on the openTree array. Here's an example:

<script setup>
  import { computed, ref, inject } from 'vue';
  import ChevronDown from '@/Shared/SVG/ChevronDown.vue';
  import TopicBlock from './TopicBlock.vue';
  import TopicBarButtons from '@/Shared/Topic/TopicBarButtons.vue';

  const props = defineProps({
    topic: Object
  });

  const open = ref(inject('openTree').includes(props.topic.id));

  function toggleOpen() {
    open.value = !open.value;
  }
</script>

<template>
  <div class="rounded p-2 flex items-center relative">
    <ChevronDown
      v-if="topic.children && topic.children.length > 0"
      width="20"
      height="20"
      @click="toggleOpen"
      :class="{'-rotate-90' : !open}"
      class="fill-white absolute -left-5"
    />
    <div class="truncate w-32 rounded px-2 py-1 text-center" :class="topic.background">
      {{ topic.name }}
    </div>
    <div class="text-base text-left hidden lg:inline ml-3 truncate w-auto">
      {{ topic.description }}
    </div>
    <TopicBarButtons :topic="topic" />
  </div>
  <div v-if="open" class="relative ml-2 lg:ml-10">
    <div>
      <TopicBlock :topics="topic.children" />
    </div>
  </div>
</template>

In this modified TopicBar component, we use the inject function to get the openTree array from the parent component, and then use the ref function to create a reactive variable called open that tracks the open/closed state of the component. We set the initial value of open to true if the openTree array includes the current topic's ID, and false otherwise.

We also define a toggleOpen function that toggles the value of open when the user clicks on the chevron icon.

Finally, we update the v-if directive on the TopicBlock component to use the open variable instead of the showChildren variable.

With these changes, the openTree array can still be used to set the initial state of the topic components, but the user can also interact with them and change their state.

bwrigley's avatar

@LaryAI This mostly works, but when I create a new topic and redirect the user back to this page with an updated array in openTree the new topic is not open.

Using the Vue dev tools I can see that the content of openTree has been updated by the Controller at Page level, but when I look at the TopicBar components openTree is empty.

If I do a manual page refresh then the current topics are open.

After I store the new subcategory I redirect the user page to the original route:

    return redirect(route('topics.home', [$topic->id]))->with(['success' => $topic->name . ' has been created']);

Please or to participate in this conversation.