Like I said before, while the existing answer might work, it might be inefficient since you are destroying and creating new widgets each time there is a change. Instead of this, you could create a function that will check if there is a change and then if there is extra or less items, the changes will take place:
from tkinter import *
import random
root = Tk()
def fetch_changed_list():
    """Function that will change the list and return the new list"""
    MAX = random.randint(5, 15)
    # Create a list with random text and return it
    items = [f'Button {x+1}' for x in range(MAX)]
    return items
def calculate():
    global items
    # Fetch the new list
    new_items = fetch_changed_list()
    # Store the length of the current list and the new list
    cur_len, new_len = len(items), len(new_items)
    # If the length of new list is more than current list then
    if new_len > cur_len:
        diff = new_len - cur_len
        # Change text of existing widgets
        for idx, wid in enumerate(items_frame.winfo_children()):
            wid.config(text=new_items[idx])
        # Make the rest of the widgets required
        for i in range(diff):
            Button(items_frame, text=new_items[cur_len+i]).pack()
    # If the length of current list is more than new list then
    elif new_len < cur_len:
        extra = cur_len - new_len
        # Change the text for the existing widgets
        for idx in range(new_len):
            wid = items_frame.winfo_children()[idx]
            wid.config(text=new_items[idx])
        # Get the extra widgets that need to be removed
        extra_wids = [wid for wid in items_frame.winfo_children()
                      [-1:-extra-1:-1]]  # The indexing is a way to pick the last 'n' items from a list
        # Remove the extra widgets
        for wid in extra_wids:
            wid.destroy()
        # Also can shorten the last 2 steps into a single line using
        # [wid.destroy() for wid in items_frame.winfo_children()[-1:-extra-1:-1]]
    items = new_items  # Update the value of the main list to be the new list
    root.after(1000, calculate)  # Repeat the function every 1000ms
items = [f'Button {x+1}' for x in range(8)]  # List that will keep mutating
items_frame = Frame(root)  # A parent with only the dynamic widgets
items_frame.pack()
for item in items:
    Button(items_frame, text=item).pack()
root.after(1000, calculate)
root.mainloop()
The code is commented to make it understandable line by line. An important thing to note here is the items_frame, which makes it possible to get all the dynamically created widgets directly without having the need to store them to a list manually.
The function fetch_changed_list is the one that changes the list and returns it. If you don't want to repeat calculate every 1000ms (which is a good idea not to repeat infinitely), you could call the calculate function each time you change the list.
def change_list():
    # Logic to change the list
    ...
    calculate() # To make the changes
After calculating the time for function executions, I found this:
| Widgets redrawn | Time before (in seconds) | Time after (in seconds) | 
| 400 | 0.04200148582458496 | 0.024012088775634766 | 
| 350 | 0.70701003074646 | 0.21500921249389648 | 
| 210 | 0.4723021984100342 | 0.3189823627471924 | 
| 700 | 0.32096409797668457 | 0.04197263717651367 | 
 
Where "before" is when destroying and recreating and "after" is only performing when change is needed.