What's the best way to handle checks after yields?

One thing that has always bothered me about writing code that yields is that I have to verify that everything I’m using is still valid before I can continue.

A couple examples:

Loading Player Data

local function onPlayerAdded(player)
	local playerData = getPlayerDataWithPerfectReliabilityAsync(player.UserId)
	if player:IsDescendantOf(Players) then
		loadData(player, playerData)
	end
end

In this example, it’s not a big deal to have a single check to confirm the player still exists. But in something where multiple yields happen in succession, it becomes very ugly:

Animated UI

In my experience, animated UI is easily the worst offender, since tweens throw an error if made for a destroyed/removed instance. This means the instance’s validity has to be re-checked before every single tween.

local function popUpThing()
	tweenPopUpOntoScreenAsync()
	if uiElementsArentParentedToNil() then
		tweenSecondaryElementAsync()
		if uiElementsArentParentedToNil() then
			for _index, element in listElements do
				fadeInListElementAsync(element)
				if not uiElementsArentParentedToNil() then
					return
				end
			end
			task.wait(3)
			if uiElementsArentParentedToNil() then
				tweenAwaySecondaryElementAsync()
				if uiElementsArentParentedToNil() then
					tweenPopUpOffScreenAsync()
				end
			end
		end
	end
end

So far the only way I’ve been able to make it look better is with inversion:

local function popUpThing()
	tweenPopUpOntoScreenAsync()
	
	if not uiElementsArentParentedToNil() then
		return
	end
	
	tweenSecondaryElementAsync()
	
	if not uiElementsArentParentedToNil() then
		return
	end
	
	for _index, element in listElements do
		fadeInListElementAsync(element)
		
		if not uiElementsArentParentedToNil() then
			return
		end
	end
	
	task.wait(3)
	
	if not uiElementsArentParentedToNil() then
		return
	end
	
	tweenAwaySecondaryElementAsync()
	
	if not uiElementsArentParentedToNil() then
		return
	end
	
	tweenPopUpOffScreenAsync()
end

This eliminates the deep indentation, but it still feels quite cluttered. Are there any alternative patterns to this that are easier to work with/read?

2 Likes

Even though I never used them in Roblox (somehow managed), you might check out monads!
Here is a video explaining the concept https://www.youtube.com/watch?v=VgA4wCaxp-Q

1 Like

TLDR: I think solutions like the “monad” / functor from the video posted above is pretty good for the UI animation case. For the more complicated situations such as player save data management, those kinds of solutions are not very good in my opinion and there is a different approach that should be used instead: constructing each pipeline as its own unique loop.


I have been noticing this problem myself recently and have come up with a particular way of thinking about it.

Any sequence of steps (I’ll refer to this as a “pipeline”) can be represented either as itself (a plain old sequence of steps) or as a loop. I’ll refer to code in the former representation as “sequential-structure” and in the latter as “loop-structure”.

These two representations are basically equivalent from a computational perspective, it’s just that for us developers writing code there are practical considerations, and established conventions and APIs, which influence which representation we use.

Using the UI animation code as an example, written naively in a sequential-structure it looks like (8 loc):

tweenPopUpOntoScreenAsync()
tweenSecondaryElementAsync()
for _, element in listElements do
	fadeInListElementAsync(element)
end
task.wait(3)
tweenAwaySecondaryElementAsync()
tweenPopUpOffScreenAsync()

This is effectively the ideal scenario and we want code that is as close to that as possible, while correctly handling the potential failure cases.

The first thing we could do is turn this into loop-structure. I have done that here. I also made a table to put the “steps” into a more declarative form, but if you really wanted you could hardcode each step into the loop itself (in this case that would be dumb, though it may be a good exercise). Here is the loop-structure (27 loc, so +19 from the sequential-structure):

local steps = {
	tweenPopUpOntoScreenAsync;
	tweenSecondaryElementAsync;
	function(state)
		local index = state.listElementsIndex or 1
		local element = listElements[index]
		if element then
			fadeInListElementAsync(element)
			state.listElementsIndex = index + 1
			return true
		else
			task.wait(3)
		end
	end;
	tweenAwaySecondaryElementAsync;
	tweenPopUpOffScreenAsync;
}

local state = {}
local stepIndex = 1
while true do
	local doStepAsync = steps[stepIndex]
	if not doStepAsync then
		break
	elseif not doStepAsync(state) then
		stepIndex += 1
	end
end

At this stage using the loop-structure is pointless – it’s very verbose and unclear, and we are basically just reinventing the programming language’s own semantics. However it gets a bit more interesting once you add the conditional checks after every yield.

Sequential-structure diff (+11 loc from original sequential-structure):

tweenPopUpOntoScreenAsync()
+if uiElementsArentParentedToNil() then
   tweenSecondaryElementAsync()
+  if uiElementsArentParentedToNil() then
      for _, element in listElements do
         fadeInListElementAsync(element)
+        if not uiElementsArentParentedToNil() then
+           return
+        end
      end
      task.wait(3)
+     if uiElementsArentParentedToNil() then
         tweenAwaySecondaryElementAsync()
+        if uiElementsArentParentedToNil() then
            tweenPopUpOffScreenAsync()
+        end
+     end
+  end
+end

Loop-structure diff (+3 loc from original loop-structure):

...
local state = {}
local stepIndex = 1
while true do
   local doStepAsync = steps[stepIndex]
   if not doStepAsync then
      break
   elseif not doStepAsync(state) then
      stepIndex += 1
   end
+  if not uiElementsArentParentedToNil() then
+     break
+  end
end

You can see by looking at the sequential-structure code that it appears to be an unrolled version of the loop-structure code.

What the loop does, fundamentally, is allow us to deduplicate logic that is shared between steps in the pipeline. It can be very good at this – in this case, we got that deduplication almost for free as very few changes needed to be made to the loop-structure representation of the code to add in the new condition.

Once you have put your code into loop-structure representation, you may be able to compress it into a form that can be reused:

local function doStepsUntilNotCondition(condition, steps)
	local stepIndex = 1
	local state = {}
	while true do
		local doStepAsync = steps[stepIndex]
		if not doStepAsync then
			break
		-- a step may return a truthy value to indicate it
		-- wants to be executed again.
		elseif not doStepAsync(state) then
			stepIndex += 1
		end
		if not condition() then
			break
		end
	end
end

The usage code then looks like this (+9 loc from original sequential-structure, which is slightly better than the naive +11):

doStepsUntilNotCondition(uiElementsArentParentedToNil, {
	tweenPopUpOntoScreenAsync;
	tweenSecondaryElementAsync;
	function(state)
		local index = state.listElementsIndex or 1
		local element = listElements[index]
		if element then
			fadeInListElementAsync(element)
			state.listElementsIndex = index + 1
			return true
		else
			task.wait(3)
		end
	end;
	tweenAwaySecondaryElementAsync;
	tweenPopUpOffScreenAsync;
})

The non-atomic loop over list elements complicates things a bit because we need to code its iteration manually. So this loop-structure is not always a winning solution, but for pipelines involving few inner loops it’s pretty good.

This process of “find the loop-structure, deduplicate it, reuse it” is where patterns such as

  • “monads” (or “functors” or whatever you want to call the thing shown in the monads video)
  • callback / signal chains
  • state machines
  • recursive functions

come from, and is why they are useful (in my understanding at least).

Another example, the "monad"/functor from the video can be written in loop-structure.
local maybe = "gustave"
local steps = {
	database.fetch;
	function(user) return user.friends end;
	function(friends) return friends.first() end;
	function(friend) return friend.gender end;
}
local stepIndex = 1
while maybe ~= nil do
	local doStepAsync = steps[stepIndex]
	if not doStepAsync then
		break
	else
		maybe = doStepAsync(maybe)
		stepIndex += 1
	end
end

Then put into reusable form:

local function functor(initial, steps)
	local stepIndex = 1
	while maybe ~= nil do
		local doStepAsync = steps[stepIndex]
		if not doStepAsync then
			break
		else
			maybe = doStepAsync(maybe)
			stepIndex += 1
		end
	end
	return maybe[1]
end

local gender = functor("gustave", {
	database.fetch;
	function(user) return user.friends end;
	function(friends) return friends.first() end;
	function(friend) return friend.gender end;
})

Often these patterns don’t appear to have loop-structure on the surface because they may be implemented and conceptualized as being recursive or “one-thing-at-a-time”. But at the end of the day any recursive function can be written as an iterative (i.e loop-structure) algorithm, and in the real world must be executed on the hardware as such. So this way of thinking gives a unified way to understand how these things work in terms of simple and widely available programming tools.

For the UI animation example, I consider using these patterns derived from reusing the loop-structure to be the most appropriate solution. It leads to the most compact usage code, but only when the pipeline itself is simple and easy to turn into a loop.

Getting reusability will be more difficult with a pipeline where different hierarchies of conditionals are required for every step, or where it is more difficult to obtain knowledge of what the “next step” even is.

So, hopefully the knowledge of where the reusability patterns come from can help you better understand why some work, and enable you to create your own patterns as needed.


As hinted at above, there is an unresolved area of concern: what do we do when the pipeline is complicated? In that case, we probably can’t reuse loop-structures easily. But if it is easier to build the loop-structure (in comparison to other strategies) then we don’t need reusability – we’d already be getting a win without it.

So, what would that look like? First, we need to go a little further with our loop structures.

The above way of putting code into a loop-structure is not the limit of how far we can go – the transformation I did above was very direct, just taking the code in front of me and making it a loop without thinking about it much.

But there remains a fundamental piece of the pipeline which has not been addressed. The Roblox game engine, the Luau language, and any yielding APIs you use are still controlling the behaviour of the pipeline by controlling when our threads get resumed and preventing us from doing things with the pipeline while their own code is in control.

For example, when we call task.wait(3), the engine controls how and when our pipeline’s thread gets resumed. (When I say “threads” I am referring to luau’s coroutines/fibers, not to be confused with OS threads or hardware parallelism.)

Because of this external control over thread execution, there is a hidden loop-structure here which is not visible on the surface. Instead this structure is present in the language’s VM, or the game engine’s main loop and signalling code. (If you want to go all the way down, you could also put the OS and physical hardware into the category, but for us those are irrelevant and inaccessible.)

As the pipeline’s control flow and its interaction with the underlying systems (e.g. the game engine, and language VM) get more complicated, the tools provided by these underlying systems (such as threads and signals) start being a hinderance rather than a help. In that scenario, I claim we are better off rejecting the tools and replacing their hidden loop-structures with our own explicit ones.

In Roblox, the furthest we can go with this is using the engine’s main loop as the driver of our loop-structure, with the rest of the pipeline’s computational logic handled ourselves from scratch (as much as we can). With the familiar UI animation example this would look like:

-- the game engine's main loop, which we are hooking into
-- here by connecting to heartbeat and using to drive our
-- loop-structure.
RunService.Heartbeat:Connect(function()

	-- for every incomplete UI animation pipeline, we do the
	-- following code...
	
	while true do
		-- (this while loop exists so that multiple steps can be
		-- performed within a single frame if some individual
		-- steps do not yield.)
	
		if not uiElementsArentParentedToNil(pipeline) then
			-- cancel the pipeline
			break
			
		elseif pipeline.thread then
			-- async code is currently executing, we need to wait
			-- for it to complete.
			break
			
		else
			local doStepAsync = pipeline.steps[stepIndex]
			if not doStepAsync then
				-- complete the pipeline
				break
			else
				-- isolate the async work so it doesn't interfere
				-- with the frame code
				pipeline.thread = coroutine.create(function()
					if not doStepAsync(pipeline.state) then
						pipeline.stepIndex += 1
					end
					pipeline.thread = nil
				end)
				task.spawn(pipeline.thread)
			end
		end
	end
end

I am going to refer to the first loop-structure I showed as the “weak” loop-structure, and this new approach as the “strong” loop-structure because it goes further in trying to encompass the fundamental loop of code execution (relying less on third party things).

I wouldn’t recommend the strong loop-structure approach for the UI animation example, or other simple cases – but for complicated things, I believe this is the most optimal code structure (for humans to work with). The savings I get from deduplicating the pipeline’s logic and not having to worry about how threads get executed recursively etc. vastly outweigh the extra verbosity and potential performance problems.

(Also, performance problems can be dealt with in ways that do not need to compromise the strong loop-structure, such as having dirty flags, or having pieces of state that are expensive to check be tracked separately and queried by the loop-structure in a way that is more efficient.)

One way you can think of this trade-off is that, where n is the complexity of the pipeline:

  • the strong loop-structure code has O(n) complexity growth but with a large constant overhead
  • the weak loop-structure or sequential-structure code has O(n^2) complexity growth with low constant overhead

When n is large, the strong loop-structure is better because it scales slower and the constant overhead doesn’t matter. But with small n, weak loop-structure or sequential-structure are preferred because they have less constant overhead.

Interestingly, it seems like when a pipeline becomes sufficiently complicated, the strong loop-structure code starts feeling like it’s more declarative than the equivalent sequential-structure or weak loop-structure code. I think this is because you are declaring what happens in each context rather than declaring the sequence of steps. With a complicated pipeline, its contextual definition is smaller and more legible than its sequential definition.

As an example of a useful strong loop-structure, I have a player save data session management pipeline.

I have never successfully managed to build the equivalent pipeline in a more sequential-structure or weak loop-structure way because there is a lot of conditional logic as well as complications like having thread resumption be done by third-parties. I just can’t hold all that in my head.

The strong loop-structure is the only one that I can really reason about clearly and take into account all the constraints and possible scenarios without much difficulty.

Here is the session management pipeline code

(This code does a lot of stuff so I have only included a vertical slice of it: just the high level session management and the save data header loading code. Functions are also defined in reverse order cause that’s better for reading here.)

local function Heartbeat(HeartbeatNow: number)
	for _, PlayerState in PlayerStates do
	
		-- If we don't have SessionState then we do not have an ongoing session
		-- and so we are either trying to load data or the player has left the
		-- game.
		if not SessionState then
			
			-- Since the player is in-game, kick off the load job if the player has
			-- been marked as being ready for save data. 
			if not PlayerState.HasLeft then
				PerformHeaderLoad(PlayerState, HeartbeatNow, PlayerState.ReadyForSaveData)
			
			-- Otherwise, we don't have an active session. But we might still have 
			-- a load job in progress, in which case we need to wait for it to 
			-- complete.
			elseif PlayerState.HeaderLoadState then
				PerformHeaderLoad(PlayerState, HeartbeatNow, false)
			
			-- If we reach here then we know for sure we don't have an active 
			-- session so we can just remove everything immediately.
			else
				Module.PlayerState_ByUserId[PlayerState.UserId] = nil
				
				-- Inform all pending product purchases that the purchase has failed.
				local PPPs = table.clone(PlayerState.PendingProductPurchases)
				table.clear(PlayerState.PendingProductPurchases)
				for _, PPP in PPPs do
					task.spawn(PPP.Thread, false)
				end
			end
		
		-- If we get here then the player is still in the game and is playing 
		-- normally.
		elseif not PlayerState.HasLeft then
			
			-- We try to load body if it doesn't exist already.
			PerformBodyLoad(PlayerState, SessionState, HeartbeatNow)
			
			local WithinGracePeriod = false
			local CanSaveHeader = false
			
			if (HeartbeatNow >= SessionState.HeaderAutosaveAt) then
				WithinGracePeriod =
					(HeartbeatNow - SessionState.HeaderAutosaveAt) <
					HeaderMaintainSessionGracePeriod
				CanSaveHeader = true
			end
			
			-- We try to continue any ongoing body save jobs. We only allow new 
			-- body save jobs to be started if we are within the grace period 
			-- (otherwise, that means our metadata save has been delayed a long 
			-- time in which case we should be prioritizing that as we need to 
			-- maintain the session lock).
			if PlayerState.LoadStatus == BUEShared.LoadStatus.everything_loaded then
				PerformBodySave(PlayerState, SessionState, HeartbeatNow, WithinGracePeriod)
				
				-- Prevent header from being saved until after body has finished 
				-- saving (or we exceed the grace period).
				if WithinGracePeriod and SessionState.BodySaveState then
					CanSaveHeader = false
				end
			end
			
			PerformHeaderSave(PlayerState, SessionState, HeartbeatNow,
				CanSaveHeader, false)
		
		-- Once the player has left the game, we try to save all changes to their 
		-- data. If any changes are made while saving we will also try to save 
		-- those.
		else
			
			local CanRelease = true
			if PlayerState.LoadStatus == BUEShared.LoadStatus.everything_loaded then
				PerformBodySave(PlayerState, SessionState, HeartbeatNow, true)
				
				-- Wait until all data saving jobs have finished before releasing.
				if SessionState.BodySaveState then
					CanRelease = false
				end
			end
			
			-- @TODO Perhaps for extra robustness, we should perform a normal
			-- session releasing header save as soon as possible, but after 
			-- that we keep trying to finish body saves. If the body saves 
			-- eventually succeed, we can then attempt to do a "no-session" 
			-- header save which will try to save to the header, but only if 
			-- nobody else has taken a session lock in the meantime.
			
			-- Try to save the header and release the session lock.
			PerformHeaderSave(PlayerState, SessionState, HeartbeatNow,
				CanRelease, CanRelease)
		end
	end
end

local function PerformBodySave(
	PlayerState: PlayerState,
	SessionState: PS_SessionState,
	HeartbeatNow: number,
	CanStartNewSave: boolean
)
	-- [...]
end

local function PerformHeaderSave(
	PlayerState: PlayerState,
	SessionState: PS_SessionState,
	HeartbeatNow: number,
	CanBeginNewSave: boolean,
	ShouldReleaseSession: boolean
)
	-- [...]
end

local function PerformHeaderLoad(
	PlayerState: PlayerState,
	HeartbeatNow: number,
	CanBeginNewLoad: boolean
)
	while true do
		local HLS = PlayerState.HeaderLoadState :: PS_HeaderLoadState
		if not HLS then
			if not CanBeginNewLoad then
				break
			else
				local HLS: PS_HeaderLoadState = {
					NextAttemptAt = -math.huge;
				}
				PlayerState.HeaderLoadState = HLS
			end
		
		elseif HLS.Thread then
			break
			
		elseif not HLS.Result then
			if not CanBeginNewLoad then
				PlayerState.HeaderLoadState = nil
				break
				
			elseif HeartbeatNow < HLS.NextAttemptAt then
				break
				
			else
				local Thread = coroutine.create(Job_PlayerStateHeader_Load)
				HLS.Thread = Thread
				task.spawn(Thread, PlayerState, HLS)
			end
		
		else
			local Result = HLS.Result
			HLS.Result = nil
			HLS.NextAttemptAt = -math.huge
			
			-- If the load failed then we just retry at some unknown point in the 
			-- future.
			if Result.LoadStatus ~= BUEShared.LoadStatus.body_loading then
				HLS.NextAttemptAt = HeartbeatNow + HeaderRetryCooldown
				PlayerState.LoadStatus = Result.LoadStatus
			
			-- Otherwise if the load succeeded, we can be happy.
			else
				local Header = Result.Header
				assert(Header)
				assert(Header.Session)
				
				local SessionState: PS_SessionState = {
					Session = Header.Session;
					Header = Header;
					HeaderAutosaveAt = HeartbeatNow + HeaderMaintainSessionPeriod;
					
					SavedGuidCount = 0;
					
					ProcessedRemoteChanges = {};
					GaveRewards_ByEventId = {};
					SavingProductPurchases = {};
				}
				PlayerState.SessionState = SessionState
				
				local GuidHistory = Header.GuidHistory
				local GuidHistoryCount = #GuidHistory
				if GuidHistoryCount < 1 then
					PlayerState.LoadStatus = BUEShared.LoadStatus.everything_loaded
				
				else
					local BLS: SS_BodyLoadState = {
						GuidIndex = GuidHistoryCount;
						GuidLoadState = {
							Guid = GuidHistory[GuidHistoryCount];
							LoadedFragments = {};
							NextFragmentIndex = 1;
							NextFragmentAt = -math.huge;
							NextFragmentRetryCount = 0;
						}
					}
					SessionState.BodyLoadState = BLS
					PlayerState.LoadStatus = BUEShared.LoadStatus.body_loading
				end
				
				ProcessRemoteChanges(PlayerState.DebugIdentifier, PlayerState, SessionState)
				
				PlayerState.HeaderLoadState = nil
				break
			end
		end
	end
end

local function Job_PlayerStateHeader_Load(
	PlayerState: PlayerState,
	HLS: PS_HeaderLoadState
)
	local Succeeded = false
	
	local TransformedLoadStatus: number?
	local TransformedPSH: PlayerSaveHeader?

	local function TransformFunction(
		RemotePSH: PlayerSaveHeader?
	): (PlayerSaveHeader?, {number}?)
		
		TransformedLoadStatus = nil
		TransformedPSH = nil
		
		local NewLoadStatus: number
		local NewPSH: PlayerSaveHeader
		
		if not RemotePSH then
			NewPSH = BUEShared.TableDeepCopy(TemplatePSH)
			NewPSH.Session = SessionCreate()
			NewLoadStatus = BUEShared.LoadStatus.body_loading
			
		else
			if (RemotePSH.Session) and
				(not SessionExpired(RemotePSH.Session))
			then
				NewLoadStatus = BUEShared.LoadStatus.head_session_is_locked
				
			else
				local MigrateSuccess = DoMigration(
					PlayerState.DebugIdentifier,
					RemotePSH,
					"PlayerSaveHeader",
					PlayerSaveHeader_VersionMajor,
					PlayerSaveHeader_VersionMinor,
					BUEMigrators.PSH_Migrators,
					BUEMigrators.PSH_Patches)
				
				if not MigrateSuccess then
					NewLoadStatus = BUEShared.LoadStatus.head_invalid_version
					
				else
					NewPSH = RemotePSH
					NewPSH.Session = SessionCreate()
					NewLoadStatus = BUEShared.LoadStatus.body_loading
				end
			end
		end
	
		TransformedLoadStatus = NewLoadStatus
		TransformedPSH = NewPSH
		
		if (NewLoadStatus == BUEShared.LoadStatus.body_loading) and
			(Module.SavingEnabled)
		then
			-- Note: If saving is disabled then the code making the request cannot 
			-- rely on the returned Header having particular values set by the 
			-- request (e.g. the session value, or migrated data).
			return NewPSH, {PlayerState.DataStoreUserId}
		else
			return nil
		end
	end
	
	local NewPSH: PlayerSaveHeader
	local NewLoadStatus: number
		
	local RequestSuccess, RequestMessage = pcall(function()
		NewPSH = State.EventsDataStore:UpdateAsync(
			string.format(PlayerSaveHeader_Key, PlayerState.DataStoreUserId),
			TransformFunction)
	end)
	
	-- If request failed then we just do nothing except report the error if
	-- it's nontrivial.
	if not RequestSuccess then
		NewLoadStatus = BUEShared.LoadStatus.head_data_store_request_fail
		
		local ErrorKind = BUEDataStoreAssistant.GetErrorKind(RequestMessage)
		if ErrorKind ~= BUEDataStoreAssistant.ErrorKind.backend then
			warn(debug.traceback(BUEDataStoreAssistant.FormatError(
				ErrorKind, RequestMessage, State.EventsDataStore, "UpdateAsync"
			)))
		end
	
	elseif not TransformedLoadStatus then
		NewLoadStatus = BUEShared.LoadStatus.head_data_store_request_fail
	
	else
		NewLoadStatus = TransformedLoadStatus
		
		-- If saving is not enabled (i.e. the UpdateAsync transform function 
		-- returned nil, cancelling the update) then Header won't have been 
		-- returned by UpdateAsync, so we need to manually set it here.
		if (not Module.SavingEnabled) and
			(TransformedLoadStatus == BUEShared.LoadStatus.body_loading)
		then
			assert(TransformedPSH)
			NewPSH = TransformedPSH
		end
	end
	
	assert(NewLoadStatus <= BUEShared.LoadStatus.body_loading)
	
	HLS.Result = {
		Success = RequestSuccess;
		Message = RequestMessage;
		LoadStatus = NewLoadStatus;
		Header = NewPSH;
	}
	HLS.Thread = nil
end

What about language features or other tools at our disposal? (Threads, recursion, etc.)

Because they often obscure the nature of how code is actually executing and require a lot of programmer effort to understand how they behave in complicated situations, I personally am wary of using language features and patterns. But at the end of the day, they are tools and do provide useful features. And we are often forced to use them when interacting with Roblox’s APIs, so sometimes we can get benefits from the language features for “free”.

Something which is a frequent PITA with loop-structure code is manually handling iteration state for inner loops. Take for example iterating over listElements in the UI animations pipeline, which requires an extra 4-5 LOC from the original for loop:

local listElementsIndex = 1
while true do
	local element = listElements[listElementsIndex]
	if not element then
		-- continue to the next step or whatever
		break
	else
		fadeInListElementAsync(element)
		listElementsIndex += 1
	end
	if not uiElementsArentParentedToNil() then
		-- cancel
		break
	end
end

One of the nice convenient things about Luau is that threads keep track of this kind of state automatically for us. So the code below is equivalent, our development effort has just been shifted to dealing with thread management rather than loop iteration management:

local iterationThreadReady = true
local iterationThread = coroutine.create(function()
	for _, element in listElements do
		fadeInListElementAsync(element)
		iterationThreadReady = true
		coroutine.yield()
	end
end)

while true do
	if coroutine.status(iterationThread) == "dead" then
		-- continue to the next step or whatever
		break
	elseif iterationThreadReady then
		iterationThreadReady = false
		task.spawn(iterationThread)
	end
	if not uiElementsArentParentedToNil() then
		-- cancel
		break
	end
end

This example sucks cause it inflates the line count. But it’s more useful when you have lots of nested loops and control flow which would be difficult to deal with it. It’s another scaling trade-off: in terms of development effort and where n is the number of inner loops, a thread management solution might be O(1) albeit with a large constant cost, and the manually tracked loop iteration solution might be O(n) with smaller constant cost.

Another example is doing recursion manually (writing an iterative loop) vs doing it automatically using function calls. The manual approach is nice - you can compress redundant/duplicate logic and better understand what the code is actually doing (e.g. building a large stack), and how it executes and jumps around your codebase. On the other hand, having to manually maintain a stack or think about your algorithm differently can be annoying, in which case it would be much more expedient to just do some recursive function calls instead.

One of the benefits of this “loop-structure” way of thinking in my opinion is that you are encouraged to pay a little more attention to what benefits language features give you and use them only when you need to. If you have the loop-structure approach in your toolkit, you don’t need to try to shoehorn other loop structures into the idiomatic for loop structure, or monad structure, or whatever else which is provided by a language or library. So I have found it valuable in removing lots of cruft and unnecessary complication due to usage of language features where they weren’t really needed – a plain old while loop could suffice instead.

Anyway, those are my thoughts on this topic. It’s quite a nightmare, haha! I will get off my soapbox now 🚶‍♂️