I also use native events in a number of places, and it can work well if you're very strict and judicious with your events, but the danger is always that one ends up with a global event bus with none of the observability or structure that make effect buses work well.
Data races are a specific example of something that happens when you don't have that observability, where the flow of events through the system produces behaviour that's dependent more on event timing than anything predictable. For example, you could have an event that triggers multiple asynchronous processes in different parts of the codebase, that in turn both access or update the same bit of state. This means that the final result of the state will be dependent on the order in which the state accesses/mutations happened. Importantly, with event buses like this, particularly ones built in a more ad-hoc way, it can be very difficult to notice when this happens without keeping the whole application (and all its possible events and responses) in your head at once.
In my experience, most of the complexity from frontend development comes from managing changing state, and one of the key problems is understanding why a given bit of state is the way that it is. I want to be able to easily trace what caused a change, where it was triggered, why it occurred, etc. But event buses can become messy because handling and dispatching a given event can happen anywhere. So even if you're sure there's no data races now, the longer the application exists and the more people work on it, the harder it becomes to ensure that data races aren't happening somewhere that you've not thought about.
That said, I agree that other state management tools that try and solve this can produce a lot of their own complexity. Redux, at least raw and with all the boilerplate, can be painful to use.
Personally, I've found signals to be one of the more convenient mechanisms for this kind of global interaction. Signals are similar to events in that they can be emitted and listened to, but they are more explicitly about the flow of data rather than arbitrary events fitting around the place. This makes it easier to hook into what's going on and see how the data is being changed. There are signals libraries for React (MobX is the classic one here), but these days I mostly just use SolidJS, which is a framework that looks a bit like React, but uses signals as its core reactive primitive.
I'm on my phone, so I can't give great code samples, but with events I'd probably write a separate class/service/tool to handle the cache busting state like this:
createCacheBust() {
// cacheBust here is a reactive getter, not just a value
// Calling `cacheBust()` in the right place will ensure that the
// effect or component will be rerun whenever
// `cacheBust` changes.
cost [cacheBust, setCacheBust] = createSignal(Date.now());
function reloadImage() {
// Update the state, trigger rerenders in any component that used `cacheBust()`
setCacheBust(Date.now());
}
return [cacheBust, reloadImage] as const;
}
export const [CACHE_BUST, reloadImage] = createCacheBust();
In simple cases like this, you can just call this "hook" globally, although in more complicated situations you might prefer using contexts to pass these values around.
In components you can just import these values and call them (or pull them out of a context and call them, if that takes your fancy):
const ImageComponent = () => {
return (
<div>
<h2>Profile Image</h2>
<img
src={`https://example.com/profile-image.jpg?cachebust=${CACHE_BUST()}`} // Image URL with cacheBust as query param
alt="Profile"
style={{ maxWidth: '200px' }}
/>
</div>
);
};
const DispatcherComponent = () => {
return (
<div>
<h2>Dispatcher Component</h2>
<button onClick={reloadImage}>Reload Image</button>
</div>
);
};
You might say that this doesn't look much different to the event bus version, and that's true, but the benefit is that we've encapsulated the state somewhat. Before, emitting an event could do pretty much anything, and the state it affected was spread out over any component that chose to listen to the event. Now, the state lives in one place, and it is mediated by the exported API. If the state starts behaving unexpectedly we know immediately where we can start our journey to figure out what's going on: the createCacheBust function.
This is just how things might look in SolidJS, but you can also use Preact which merges the React and signal-based worlds in a different way. But either way, I really recommend exploring signals as a way of being able to wrap the event-based logic that you're currently using in a simpler, more state-based parcel.