Event Bus - InsureMO/rainbow-d9 GitHub Wiki

Event Bus

One of the most interesting challenges in using React is communication between child components. We are familiar with two scenarios:

  1. React components are encapsulated, and only Props can interact with their owner (usually the parent component). Callback functions are a common way to notify the owner. However, if there are many layers of components, traversing multiple layers of callbacks can make the code very difficult to read and maintain.
  2. Since the inception of React, the industry has had the concept of Flux, with popular implementations including Redux, Mobx, etc., which are essentially extensions and enhancements of the Flux design philosophy. However, the biggest problem with Flux is that in weaving a large React application, the only way to subscribe and publish makes the management of the entire application extremely complex, eventually leading to collapse or struggling on its edge.

So imagine this scenario: we live in a city. If the entire city has only one news channel, we would need to sift through countless types of news to find what we care about, which is a time-consuming and laborious task. Moreover, maintaining such a news channel may require numerous employees for organization and operation. Therefore, if we only care about news in our own community, should we establish a community channel? In this way, citizens who are only interested in community news can simply subscribe to the community channel. And this community channel wouldn't require as many employees for operation, after all, communities have their own preferences and don't need to cover all types of news. So, based on this idea, we need to create different news channels for different communities and citizens with different preferences, so that news can be maintained at the minimum cost and delivered to citizens who care about it at the lowest cost possible.

Back to React, we'll find that React has already provided a "news channel" that can be defined in scope, and most of us will easily recognize this as Context. We'll notice that in most Context examples, it's merely used to pass some data or functions through layers of components for convenient usage, fundamentally not much different from Callbacks, just saving us from a lot of ugly Prop declarations. Let's take a look at an example.

Suppose we have a community with two citizens, John and Jane, who want to communicate. Let's first create this community so that both citizens can speak, but what they say can only be heard by themselves and not by the other.

interface Citizen {
	name: string;
}

interface HouseProps {
	citizen: Citizen;
}

const House = (props: HouseProps) => {
	const {citizen} = props;

	const [word, setWord] = useState('');

	const onSaySomething = (event: ChangeEvent<HTMLInputElement>) => {
		setWord(event.target.value);
	};

	return <>
		<span>{citizen.name}</span>
		<input value={word} onChange={onSaySomething}/>
	</>;
};

const Community = () => {
	const citizens = [{name: 'John'}, {name: 'Jane'}];
	return <>
		<House citizen={citizens[0]}/>
		<House citizen={citizens[1]}/>
	</>;
};

Now, let's use Callbacks to enable John and Jane to communicate with each other.

  • Step one, we need to add a callback function for speaking out loud, as well as informing each citizen about the last thing someone said to them.

    interface Citizen {
    	name: string;
    +	say: (word: string) => void;
    +	lastWord?: string;
    }
  • Step two, we'll redesign the house to allow citizens to use callback functions for speaking, and to display the last thing said to them.

    const House = (props: HouseProps) => {
    	const {citizen} = props;
    
    	const [word, setWord] = useState('');
    
    	const onSaySomething = (event: ChangeEvent<HTMLInputElement>) => {
    		setWord(event.target.value);
    +		citizen.say(event.target.value);
    	};
    
    	return <>
    		<span>{citizen.name}</span>
    		<input value={word} onChange={onSaySomething}/>
    +		<span>{citizen.lastWord}</span>
    	</>;
    };
  • Step three, the community will add a whisper box, which will transmit a message to another citizen when one citizen speaks.

    + interface Whispers {
    +	toJohn?: string;
    +	toJane?: string;
    +}
    
    const Community = () => {
    +	const [words, setWords] = useState<Whispers>({});
    -	const citizens = [{name: 'John'}, {name: 'Jane'}];
    +	const citizens = [
    +		{name: 'John', say: (word: string) => setWords({...words, toJane: word}), lastWord:   words.toJohn},
    +		{name: 'Jane', say: (word: string) => setWords({...words, toJohn: word}), lastWord:   words.toJane}
    +	];
    
    	return <>
    		<House citizen={citizens[0]}/>
    		<House citizen={citizens[1]}/>
    	</>;
    };

Here we go. John and Jane can now communicate with each other. However, we've encountered two issues:

  • When one citizen speaks to another, the community inexplicably repeats the last thing said to the speaking citizen (because setWords updates the state, refreshing all child components).
  • If this community has 100 citizens or even 10,000 citizens, it seems like this approach isn't sustainable.

Let's hold off on addressing the two issues mentioned above and instead consider what happens if we have a larger community consisting of many smaller communities, where citizens from different communities also need to communicate with each other. Here, I won't provide code, as this essentially boils down to the problem of callback function "prop drilling," which can make the code quite ugly. To tackle this issue, we introduce Context. Let me see how Context resolves the problem of callback function prop drilling.

interface Citizen {
	name: string;
}

interface HouseProps {
	citizen: Citizen;
}

const House = (props: HouseProps) => {
	const {citizen} = props;

	const {words, setWords} = useLargeCommunityContext();
	const [word, setWord] = useState('');
	const [toWho, setToWho] = useState('');

	const onToWhoChanged = (event: ChangeEvent<HTMLInputElement>) => {
		setToWho(event.target.value);
	};
	const onSaySomething = (event: ChangeEvent<HTMLInputElement>) => {
		setWord(event.target.value);
		setWords(words => ({...words, [toWho]: word}));
	};

	return <>
		<span>{citizen.name}</span>
		<input value={toWho} onChange={onToWhoChanged}/>
		<input value={word} onChange={onSaySomething}/>
		{/** @ts-ignore */}
		<span>{words[`to${citizen.name.charAt(0).toUpperCase() + citizen.name.slice(1)}`]}</span>
	</>;
};

interface CommunityProps {
	name: string;
	citizens: Array<Citizen>;
}

const Community = (props: CommunityProps) => {
	const {name, citizens} = props;

	return <>
		<span>{name}</span>
		{citizens.map(citizen => {
			return <House citizen={citizen}/>;
		})}
	</>;
};

interface Whispers {
	toJohn?: string;
	toJane?: string;
	toDavid?: string;
	toSally?: string;
}

interface LargeCommunityContext {
	words: Whispers;
	setWords: Dispatch<SetStateAction<Whispers>>;
}

interface LargeCommunityWelfare {
	words: Whispers;
	setWords: Dispatch<SetStateAction<Whispers>>;
}

interface LargeCommunityProps extends LargeCommunityWelfare {
	children?: ReactNode;
}

const Context = createContext<LargeCommunityContext>({} as LargeCommunityContext);
Context.displayName = 'LargeCommunity';

export const LargeCommunityContextProvider = (props: LargeCommunityProps) => {
	const {words, setWords, children} = props;

	const bus = {words, setWords};

	return <Context.Provider value={bus}>
		{children}
	</Context.Provider>;
};

export const useLargeCommunityContext = () => useContext(Context);

const LargeCommunity = () => {
	const [words, setWords] = useState<Whispers>({});
	const communities = [
		{name: 'uptown', citizens: [{name: 'John'}, {name: 'Jane'}]},
		{name: 'downtown', citizens: [{name: 'David'}, {name: 'Sally'}]}
	];
	return <LargeCommunityContextProvider words={words} setWords={setWords}>
		{communities.map(community => {
			return <Community name={community.name} citizens={community.citizens}/>;
		})}
	</LargeCommunityContextProvider>;
};

Great! After some restructuring, we now have a large community that can contain several smaller communities. Each small community can have multiple citizens who can freely communicate with each other. If you've noticed the adjustment of House, we can now facilitate point-to-point conversations, where a citizen can speak directly to whoever they wish, as long as they specify the intended listener. Alright, up to this point, we no longer have to worry about how many small communities or citizens there are. This is a significant improvement over callback functions, and we've addressed the second issue mentioned earlier. All it took was a minor modification to the houses and moving the secret message box from the small community to the larger community.

Note

Actually, it's not that callback functions can't handle an unlimited number of small communities and citizens; it's just that callback functions lack the robustness of Context.

However, let's not forget, we still have one issue. Please note the implementation mentioned earlier: in House, when a citizen speaks, they actually invoke the setWords function of Context. Now, this function resides in the state of LargeCommunity. In other words, every statement uttered by a citizen ultimately triggers a change in the state of the large community's secret message box. Then, this updated content is indiscriminately broadcasted to every citizen in every small community. In reality, the secret message box only contains one new message for a particular citizen! Imagine if there are 100 small communities, each with 100 citizens! It's unimaginable how much bandwidth (computational overhead, virtual DOM comparisons, etc.) we would need to convey just one message. This is clearly not what we want to see.

So, let's continue with the upgrading.

Our only issue now is to avoid using all the resources for one citizen's utterance, causing all citizens to refresh and display the last thing they heard (all sub-components refreshed). Since that's the case, obviously, we need to decentralize the right to refresh based on the last heard utterance from the large community to each citizen. This way, each citizen can determine for themselves whether the last conversation in the community's secret message box is intended for them and then decide whether to refresh that statement.

Let's take another look at the implementation after the second major overhaul:

interface Citizen {
	name: string;
}

interface HouseProps {
	citizen: Citizen;
}

const House = (props: HouseProps) => {
	const {citizen} = props;

	const {say, listen, offline} = useLargeCommunityContext();
	const [word, setWord] = useState('');
	const [toWho, setToWho] = useState('');
	const [lastWord, setLastWord] = useState('');
	useEffect(() => {
		const onLastWord = (word: string) => {
			setLastWord(word);
		};
		listen(citizen.name, onLastWord);
		return () => {
			offline(citizen.name, onLastWord);
		};
	}, [say, listen, offline]);

	const onToWhoChanged = (event: ChangeEvent<HTMLInputElement>) => {
		setToWho(event.target.value);
	};
	const onSaySomething = (event: ChangeEvent<HTMLInputElement>) => {
		setWord(event.target.value);
		say(toWho, word);
	};

	return <>
		<span>{citizen.name}</span>
		<input value={toWho} onChange={onToWhoChanged}/>
		<input value={word} onChange={onSaySomething}/>
		<span>{lastWord}</span>
	</>;
};

interface CommunityProps {
	name: string;
	citizens: Array<Citizen>;
}

const Community = (props: CommunityProps) => {
	const {name, citizens} = props;

	return <>
		<span>{name}</span>
		{citizens.map(citizen => {
			return <House citizen={citizen}/>;
		})}
	</>;
};

interface LargeCommunityContext {
	say: (toWho: string, word: string) => void;
	listen: (toWho: string, listener: (word: string) => void) => void;
	offline: (toWho: string, listener: (word: string) => void) => void;
}

interface LargeCommunityWelfare {
	say: (toWho: string, word: string) => void;
	listen: (toWho: string, listener: (word: string) => void) => void;
	offline: (toWho: string, listener: (word: string) => void) => void;
}

interface LargeCommunityProps {
	children?: ReactNode;
}

const Context = createContext<LargeCommunityContext>({} as LargeCommunityContext);
Context.displayName = 'LargeCommunity';

export const LargeCommunityContextProvider = (props: LargeCommunityProps) => {
	const {children} = props;

	const [bus] = useState<LargeCommunityWelfare>(() => {
		const emitter = new EventEmitter().setMaxListeners(999999);
		return {
			say: (toWho: string, word: string) => emitter.emit('say-a-word', toWho, word),
			listen: (toWho: string, listener: (word: string) => void) => {
				if (!emitter.rawListeners(toWho).includes(listener)) {
					emitter.on(toWho, listener);
				}
			},
			offline: (toWho: string, listener: (word: string) => void) => emitter.off(toWho, listener)
		};
	});

	return <Context.Provider value={bus}>
		{children}
	</Context.Provider>;
};

export const useLargeCommunityContext = () => useContext(Context);

const LargeCommunity = () => {
	const communities = [
		{name: 'uptown', citizens: [{name: 'John'}, {name: 'Jane'}]},
		{name: 'downtown', citizens: [{name: 'David'}, {name: 'Sally'}]}
	];
	return <LargeCommunityContextProvider>
		{communities.map(community => {
			return <Community name={community.name} citizens={community.citizens}/>;
		})}
	</LargeCommunityContextProvider>;
};

Bingo! The upgrading of the large community's secret message box is complete! Every citizen can now decide when to go online or offline. The large community will no longer require every citizen to display the last thing they heard just because there's a new statement. Each citizen now has the right to decide when to refresh and display the last statement heard.

Conclusion

We no longer need to explain why there's no longer a forced refresh of all citizens, as there isn't even a changing state within the large community, which is easy to understand. We simply built an Event Bus through Context, allowing each citizen to decide how to use this event bus.

Important

Did you notice that this is the private chat channel provided by the large community, only capable of one-on-one communication? So what if we need broadcasting or roundtable meetings? It seems we don't need to discuss these issues further; we already have the large community telecommunications bureau. All we need to do is upgrade the telecommunications service, right?

⚠️ **GitHub.com Fallback** ⚠️