I refactored my Discord clone app to see if using Redux helps with state management, and here is what I found…

anthonyzhang220

Anthony Zhang

Posted on June 24, 2024

I refactored my Discord clone app to see if using Redux helps with state management, and here is what I found…

The Story

Before I chose the tech stack for my Discord clone app, I didn't expect this would be such a complex project so Redux was not part of the stack initially. The purpose of building this app, in the beginning, is I have been using Discord since 2017, and it is such a fun place to hang out with my friends, play games, chat, etc, so I want to replicate the experience. Later on, as I started to get a hang of modern web technologies, I became curious about what it takes to build Discord with the technologies I'm familiar with, for example, React.js.

The Problem

It took me about 2 to 3 months to finish building the app with all the basic discord features ready for end users to use, such as direct messaging, group messaging, voice calling, friend invites, server and channel invites, group audio and video chat, and more. However, the code I wrote made me want to stop myself from even beginning this project because it is really hard to maintain and the code is barely readable. My wish is to keep this project alive and add more features later on, so I decided to refactor it no matter what.

Evaluation before Refactoring

It is very obvious to me that my main App.js file is too bulky. It contains almost all states, functions, hooks, and so on with about 1000 lines of code. Even though the initial design of dividing each section to separate React components is quite nice, to be honest, there is still too much props drilling, which results in chaotic state management. Just to give you an idea of what my App.js looks like before refactoring.


Here is my react component with all the props passed to it,

<Route
    index
    element={
        <Fragment>
            <Channel
                currentServer={currentServer}
                setCurrentServer={setCurrentServer}
                currentUser={currentUser}
                currentChannel={currentChannel}
                setCurrentUser={setCurrentUser}
                signOut={signOut}
                handleAddChannel={handleAddChannel}
                handleCurrentChannel={handleCurrentChannel}
                channelModal={channelModal}
                setChannelModal={setChannelModal}
                handleChannelInfo={handleChannelInfo}
                newChannel={newChannel}
                voiceChat={voiceChat}
                setVoiceChat={setVoiceChat}
                currentVoiceChannel={currentVoiceChannel}
                setCurrentVoiceChannel={setCurrentVoiceChannel}
                handleLocalUserLeftAgora={handleLocalUserLeftAgora}
                muted={muted}
                defen={defen}
                handleDefen={handleDefen}
                handleVideoMuted={handleVideoMuted}
                handleVoiceMuted={handleVoiceMuted}
                voiceConnected={voiceConnected}
                isSharingEnabled={isSharingEnabled}
                isMutedVideo={isMutedVideo}
                screenShareToggle={screenShareToggle}
                stats={stats}
                connectionState={connectionState}
            />
            {
                voiceChat ?
                    <VoiceChat
                        voiceChat={voiceChat}
                        currentVoiceChannel={currentVoiceChannel}
                        config={config}
                        currentUser={currentUser}
                        isMutedVideo={isMutedVideo}
                        remoteUsers={remoteUsers}
                        setRemoteUsers={setRemoteUsers}
                        currentAgoraUID={currentAgoraUID}
                    />
                    :
                    <Chat
                        currentUser={currentUser}
                        currentServer={currentServer}
                        currentChannel={currentChannel}
                        handleAddMessage={handleAddMessage}
                        handleChatInfo={handleChatInfo}
                        currentMessage={currentMessage}
                    />

            }
        </Fragment>
    }
/>
Enter fullscreen mode Exit fullscreen mode

Here are the states and useEffect handling user authentications,

const navigate = useNavigate();
const GoogleProvider = new GoogleAuthProvider();
const FacebookProvider = new FacebookAuthProvider();
const TwitterProvider = new TwitterAuthProvider();
const GithubProvider = new GithubAuthProvider();

//show modal for new server/channel
const [channelModal, setChannelModal] = useState(false);
const [serverModal, setServerModal] = useState(false);

//add new server/channel
const [newChannel, setNewChannel] = useState("")
const [newServerInfo, setNewServerInfo] = useState({ name: "", serverPic: "" });
const [serverURL, setServerURL] = useState(null);
const [file, setFile] = useState(null);

//current Login USER/SERVER/CHANNEL
const [currentUser, setCurrentUser] = useState({ name: null, profileURL: null, uid: null, createdAt: null });
const [currentServer, setCurrentServer] = useState({ name: "", uid: null });
const [currentChannel, setCurrentChannel] = useState({ name: "", uid: null });
const [currentMessage, setCurrentMessage] = useState("");

const [imageDir, setImageDir] = useState("")
const [isLoading, setIsLoading] = useState(false);

const [friendList, setFriendList] = useState([])

//google sign in with redirect
const googleSignIn = () => {
    signInWithRedirect(auth, GoogleProvider)
}

const facebookSignIn = () => {
    signInWithRedirect(auth, FacebookProvider)
}

const twitterSignIn = () => {
    signInWithRedirect(auth, TwitterProvider)
}
const githubSignIn = () => {
    signInWithRedirect(auth, GithubProvider)
}

//auth/login state change
useEffect(() => {
    const loginState = onAuthStateChanged(auth, (user) => {
        console.log(user)
        if (user) {
            const userRef = doc(db, "users", user.uid);

            getDoc(userRef).then((doc) => {
                const has = doc.exists();
                if (has) {
                    setCurrentUser({ name: doc.data().displayName, profileURL: doc.data().profileURL, uid: doc.data().userId, createdAt: doc.data().createdAt.seconds, status: doc.data().status })
                } else {
                    setDoc(userRef, {
                        displayName: user.displayName,
                        email: user.email ? user.email : "",
                        profileURL: user.photoURL,
                        userId: user.uid,
                        createdAt: Timestamp.fromDate(new Date()),
                        status: "online",
                        friends: [],
                    }).then((doc) => {
                        setCurrentUser({ name: doc.data().displayName, profileURL: doc.data().profileURL, uid: doc.data().userId, createdAt: doc.data().createdAt.seconds, status: doc.data().status })
                    })
                }
            })

            //if user not in the storage, add to the local storage
            if (!localStorage.getItem(`${user.uid}`)) {
                localStorage.setItem(`${user.uid}`, JSON.stringify({ defaultServer: "", defaultServerName: "", userDefault: [] }));
            } else {
                const storage = JSON.parse(localStorage.getItem(`${user.uid}`))
                setCurrentServer({ name: storage.defaultServerName, uid: storage.defaultServer })
                setCurrentChannel({ name: storage.userDefault.lengh == 0 ? "" : storage.userDefault.find(x => x.currentServer == storage.defaultServer).currentChannelName, uid: storage.userDefault.find(x => x.currentServer == storage.defaultServer).currentChannel })
            }
            navigate('/channels')
        } else {
            updateDoc(doc(db, "users", currentUser.uid), {
                status: "offline",
            })
            setCurrentUser({ name: null, profileURL: null, uid: null, status: null })
            navigate('/')

        }

    })

    return () => {
        loginState();
    }

}, [auth])

//auth sign out function
const signOut = () => {
    auth.signOut().then(() => {

        const userRef = doc(db, "users", currentUser.uid)
        updateDoc(userRef, {
            status: "offline"
        })
        setCurrentUser({ name: null, profileURL: null, uid: null, createdAt: null })
    }).then(() => {
        navigate("/", { replace: true })
    })
}

Enter fullscreen mode Exit fullscreen mode

Here are some of the states and functions handling voice group chat using Agora SDK,

const [voiceChat, setVoiceChat] = useState(false);
const [currentVoiceChannel, setCurrentVoiceChannel] = useState({ name: null, uid: null })

const [config, setConfig] = useState(AgoraConfig)

const [isSharingEnabled, setIsSharingEnabled] = useState(false)
const [isMutedVideo, setIsMutedVideo] = useState(true)
const [agoraEngine, setAgoraEngine] = useState(AgoraClient);
const screenShareRef = useRef(null)
const [voiceConnected, setVoiceConnected] = useState(false);
const [remoteUsers, setRemoteUsers] = useState([]);
const [localTracks, setLocalTracks] = useState(null)
const [currentAgoraUID, setCurrentAgoraUID] = useState(null)
const [screenTrack, setScreenTrack] = useState(null);

const FetchToken = async () => {
    return new Promise(function (resolve) {
        if (config.channel) {
            axios.get(config.serverUrl + '/rtc/' + config.channel + '/1/uid/' + "0" + '/?expiry=' + config.ExpireTime)
                .then(
                    response => {
                        resolve(response.data.rtcToken);
                    })
                .catch(error => {
                    console.log(error);
                });
        }
    });
}

useEffect(() => {
    setConfig({ ...config, channel: currentVoiceChannel.uid })
}, [currentVoiceChannel.uid])

const [connectionState, setConnectionState] = useState({ state: null, reason: null })

useEffect(() => {
    agoraEngine.on("token-privilege-will-expire", async function () {
        const token = await FetchToken();
        setConfig({ ...config, token: token })
        await agoraEngine.renewToken(config.token);
    });
    //enabled volume indicator
    agoraEngine.enableAudioVolumeIndicator();

    agoraEngine.on("volume-indicator", (volumes) => {
        handleVolume(volumes)
    })
    agoraEngine.on("user-published", (user, mediaType) => {
        console.log(user.uid + "published");
        handleUserSubscribe(user, mediaType)
        handleUserPublishedToAgora(user, mediaType)
    });
    agoraEngine.on("user-joined", (user) => {
        handleRemoteUserJoinedAgora(user)
    })
    agoraEngine.on("user-left", (user) => {
        console.log(user.uid + "has left the channel");
        handleRemoteUserLeftAgora(user)
    })
    agoraEngine.on("user-unpublished", (user, mediaType) => {
        console.log(user.uid + "unpublished");
        handleUserUnpublishedFromAgora(user, mediaType)
    });

    agoraEngine.on("connection-state-change", (currentState, prevState, reason) => {
        setConnectionState({ state: currentState, reason: reason })
    })

    return () => {
        removeLiveUserFromFirebase(currentAgoraUID)

        for (const localTrack of localTracks) {
            localTrack.stop();
            localTrack.close();
        }
        agoraEngine.off("user-published", handleRemoteUserJoinedAgora)
        agoraEngine.off("user-left", handleRemoteUserLeftAgora)
        agoraEngine.unpublish(localTracks).then(() => agoraEngine.leave())
    }

}, []);
Enter fullscreen mode Exit fullscreen mode

And this is only part of the code for my main App.js file. The reason I was putting all of them in here is because a lot of the state needs to be shared across child components, so I have to put it at the top of the DOM tree to be passed. (If you want to take a look at my old App.js file, here it is for you.)


Refactoring

The refactoring consists of a few steps. First, I need to divide them into different slices with different features, such as authentications, voice chat(Agora states), channel, server, message input, user, etc. Second, to further shrink the size of my App.js file, I need to group functions with similar purposes and put them into separate folders, for example, a util folder for all the utility functions, a handler/service folder for all the handlers such as user input handlers, voice chat room control handlers, server handlers, and more.

The Result

Here is the shrunken-down version of my App.js file after refactoring. It serves as the project entry point and only deals with routing making the code clean.

function App() {
    const dispatch = useDispatch()
    const { user } = useSelector((state) => state.auth)
    const { isVoiceChatPageOpen } = useSelector(state => state.voiceChat)
    const { currVoiceChannel } = useSelector(state => state.channel)

    const navigate = useNavigate();

    useEffect(() => {
        const unsubscribe = onAuthStateChanged(auth, async (user) => {
            if (user) {
                const userRef = doc(db, "users", user.uid);
                const userDoc = await getDoc(userRef);

                if (userDoc.exists()) {
                    const userData = userDoc.data();
                    console.log("userData", userData)
                    dispatch(setUser({ ...userData }))
                } else {
                    const userDoc = await setDoc(userRef, {
                        displayName: user.displayName,
                        email: user.email ? user.email : "",
                        avatar: user.photoURL,
                        id: user.uid,
                        createdAt: Timestamp.fromDate(new Date()),
                        status: "online",
                        friends: [],
                        bannerColor: await getBannerColor(user.photoURL)
                    })
                    if (userDoc) {
                        dispatch(setUser(userDoc))
                    }
                }
                getSelectStore()
                dispatch(setIsLoggedIn(true))
                navigate("/channels")
            } else {
                dispatch(setUser(null))
                dispatch(setIsLoggedIn(false))
                navigate("/")
            }
        })

        return unsubscribe;
    }, [auth])

    return (
        <ThemeContextProvider>
            <CssBaseline />
            <Error />
            <Routes>
                <Route path="/" element={<LoginPage />} />
                <Route path="/reset" element={<ResetPasswordPage />} />
                <Route path="/register" element={<RegisterPage />} />
                <Route element={
                    <Box className="app-mount">
                        <Box className="app-container" >
                            <Outlet />
                        </Box>
                    </Box>
                } >
                    <Route path="/channels"
                        element={
                            <Fragment>
                                <ServerList />
                                <Outlet />
                            </Fragment>
                        }>
                        <Route index
                            element={
                                <Fragment>
                                    <Channel />
                                    {
                                        isVoiceChatPageOpen ?
                                            <VoiceChat currVoiceChannel={currVoiceChannel} />
                                            :
                                            <Chat />
                                    }
                                </Fragment>
                            }
                        />
                        <Route
                            path='/channels/@me'
                            element={
                                <Fragment>
                                    <DirectMessageMenu />
                                    <DirectMessageBody />
                                </Fragment>
                            } />
                    </Route>
                </Route>
                <Route path="*" element={<PageNotFound />} />
            </Routes>
        </ThemeContextProvider>
    )
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Here is the Redux state graph. Almost all states are managed inside Redux.

Redux Graph 1

Redux Graph 2

Does Redux help with state management? For the obvious reason, yes it does. It makes my code way easier to read and maintain. Also with Redux, I can access the global state wherever I want. For example, both channel and voice chat components need to access the mic mute/unmute and camera on/off state, I can easily grab them in each child component.


More Questions Ahead

As I was refactoring the code base, a question was raised in my head. Every time we fetch data from APIs, we tend to sync and store them inside Redux and reflect the data in our UI. This gives me the feeling that Redux serves almost the same role as the backend database, but works as a constantly changing database for the frontend part. It's like creating another database in the frontend part only for displaying data in the UI, which I think might be redundant because we already have a database, and we are communicating with it by APIs. Why do we need an extra layer? Why can't we just simply display the data as we received them from the backend?

After some reading and research, it turns out we have created many solutions to my question. In the MVC model, we have a model, view, and controller system, where views are directed by controllers to represent the models. Many frontend technologies use this MVC model like Ruby on Rails and Django. Recently, HTMX has gained more popularity among backend developers because it is server-centric. When a new piece of data is received, it is immediately updated on the website, while keeping it interactive. Here is a link to the video from DjangoCon 2022 where a HTMX demo is being presented by David Guillot.

There are certainly more solutions. But Redux for sure plays an essential role in the React ecosystem. It is extremely useful when we are building large and complex web applications where we need state management across the project. But on the downside, it creates more boilerplate code, in my Discord clone as well. This is like creating another abstraction on top of React, which is an abstraction itself, but we kind of stuck to using it. I would also think that Redux is a more front-end approach to how we handle data, especially in SPA. As modern web technologies evolve and develop, we will see more ideas and solutions. With the latest React v18 and 19 with RSC, we will start to see more things done in the server component on the server side. This to me feels like a trend, and I find it very interesting that the front end is trying to move closer to the back end, and the back end is trying to move closer to the front end with new techs like HTMX. I'm excited to see what is coming for modern web development in the near future.


If you would like to check out my whole Discord clone project, here is the GitHub link to my repository. If you like my project, please don't hesitate to leave a star.

Discord Clone Screenshot
Happy Coding!

💖 💪 🙅 🚩
anthonyzhang220
Anthony Zhang

Posted on June 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related