At Axon, I have been maintaining and developing the core media libraries that powers most of our media players across our set of products with the mission "Write code, save life". In this post, I would like to share my recent discovery about how the current architecture could be improved and be more reusable. Stay tuned 😎
Previously, I did share my invented media architecture to work in harmony with UI frameworks. Not long after that, I crafted a dedicated package so-called next-media to serve the higher purpose at Axon - "unify media experience". The biggest goals of the package are maintainability and flexibility. Any developers should be able to jump in the documentation and start customize their own media players within matter of hour(s) or less if they are already familiar themselves with media elements in the web. With respect to maintainability, I spent a while section explained on code structure, couple of decision I have made and reference links on HTMLMediaElement and Media Source API for loading media stream dynamically. Performance was not part of the big picture as I wanted to make the library minimal, simple yet powerful and get everyone up and running as quickly as possible. I did not use anything fancier than just React, HLS.js and our company's UI design system. Recently, couple of my colleagues came to me and share their concerns about performance hit they face when using next-media in practice. This post will go through and explain in detail how I made performance optimization possible and why I think it's not necessary in our use cases at Axon 😊
First and foremost, I have used React's Context underneath the hood to power our media player. This comes with pros and cons as followings:
The first drawback might seem as bad as it sounds. Many of many colleagues thought it's a wrong way to do it even. I have used next-media in one of our project at Axon to tailor one video players that needs to interact with many components located across the page. Hence, I decided to wrap everything inside MediaProvider even for the ones that do not consume media context at all. This might seem to be insane amount of overhead in execution of React's rendering process. It's actually not as bad as most of us might be thinking right now. React has a very well written section on how to optimize React for performance . The one we should concern the most here is shouldComponentUpdate section as that's exactly what next-media has been using in action since its first day of existence. Specifically, MediaProvider is described as C8 component in the post. Even though MediaProvider component re-render itself on every state update. The component located inside its children won't be re-rendered unnecessarily unless it's part of the MediaContext's consuming flow. I won't bother explain much of that in this post but leverage awesome post by Kent C Dodd here.
The second drawback is the one that I want to light up the most in this post since I spent the last weekend working on it 😄. It's super bothering me when I have to see some (expensive) components have to be re-rendered on media context change. For instance, PlayToggle button component should not be re-rendered when currentTime (the most frequent change state) of the media element changes. Unfortunately, since it's React (You won't get performance optimization by default as in Vue - I'm bias and I'm a big fan 😝) and the connected component did not know whether or not it should re-render on media context change. Here we go my adventure on how I have incrementally optimized next-media's performance.
The quickest and simplest one would be splitting connected component into two separate components for optimization (similar to container and presentational pattern famous back in the day of React's world). Now we can apply React.memo or even useMemo hook if that kind of API fancies your tickle 😄. The presentation component will be checked on props changes. In case of PlayToggle button component, the props it receives will be paused and setPaused. These are simple to compare when React's re-rendering process kicks in. This solution works fairly okay but does not seem to be good enough.
Up until this point, I have placed all media state inside MediaProvider component which leads to reasonably small overhead inside that component. I got an idea of applying exactly what Recoil does in their project, I decided to extract the media states out of MediaProvider component while making sure all connected components got re-rendered on state update. Here is the fun began, I knew I needed some kind of observable implementation to make this work. I imagined I was standing in front of ton of solutions that offer similar capabilities. We got Redux, MobX, Stately, UnState, XState et cetera. I'm kidding, I know I would go for XState since its my favorite state management library of all time. Redux is bloated, MobX is quite cumbersome and heavy weight, etc. I wanted something extremely lightweight and works. XState FSM and its accompany connector to work with React serve the purpose so well. 3 KB is all it takes to resemble the whole solution to work beautifully.
In XState, we have a concept of state machine which is exactly what I need to use to store media context state. I created a very simple state machine for media called mediaMachine as the following:
It has one finite state only called ready and some infinite states (media state) placed in context of XState. Now, all I need to is hook this up to MediaProvider. On initial load, I start the machine and place it inside ref for later use. Since there is not any state placed inside MediaProvider, it won't be re-render on state change anymore and the media state is placed safely in mediaMachine's context.
Next, I need to update the useMediaContext hook to consume data from mediaMachine and pass down to connected components.
Now, the MediaProvider won't be re-rendered anymore on state update since its media state got hoisted to the cloud just like Recoil 😎
But, this solution only improve the rendering process inside MediaProvider only. All connected components still need to be re-rendered on un-consumed state as demonstrated in the case of PlayToggle button component above 😌
I got so closed to where I wanted. I could have just combined the first and second tries solutions to have the full picture's repainted with performance. But It doesn't feel right, I could not feel completely fulfilled. I kept researching over the XState repos and came across this discussion on useService Performance Recommendation. And voila, it's exactly what I need for my next-media to be performant. I created a new hook called usePartialMediaContext. As the name indicates, the consumer of this hooks will only use partial state of the media context values, this hook WILL NOT trigger the re-rendering process unless one or more of the directly consumed states change.
And here is what PlayToggle button component looks like in performance mode 😍
There was still one thing that really bogged me down. After third try, I had one media performant hook in place to return partial state and normal media hook to return full state. This increases the API interface for developers to learn and more decisions to make when to use which hook. What if I could combine them into one hook, provide strongly typing out of the box, reusable code in other places such as HOC and Render Prop, and option to select partial state for performant purposes. This is where I struggled a bit with TypeScript since I'm not an expert 😆. After couple of researching hours, I found the solution. It looks almost identical to useSelector from React-Redux library with one subtile difference. The useMediaContext hook below will return by default all state from media context values if custom selector is not passed in and the selective values will be returned otherwise with typing support. The solution is pretty clean as below 😬
There is still one last minor thing I think we could and should improve. As we are using useEffect hooks. The dependency list can cause the first function argument of useEffect hook gets re-run again, again and again. Whilst making improvement over this next-media, I happened to come across this line of code from React-Redux library. They decided to store all states in refs instead of useState hook as usual and use a trick of forceUpdate. This was exactly what I needed for next-media's useMediaContext hook to be performant as the selector inline function will be different on every render. And here it is the improvement 🤘
So far so good, you guys might think I just solved my colleagues' performance hit issues by applying minimal but complete XState and recommendation from its creators. The final decision for my next-media was to keep it as-is. Even though next-media works wonderfully and supercharged by XState. It does not mean I want to ship it to production. Let's relax, sit back and revise our Axon's use cases I set for next-media in the first place 🤩
You guys can play around with the demo in this CodeSandbox link 🥳
In conclusion, I'm very happy that next-media is on the right track. Nothing goes in vain though, I do React for a living but Vue is my passion. In Vue, they just do it for ya and I don't have to make a lot of decisions of performance optimization as in React. Anyways, I love working at Axon to serve our mission "Write code, save life". Thus, technologies are not my biggest concerns at of writing 😄. Previously, I have implemented the media architecture in Vue, Angular and React with different state management systems accordingly (React's Context, Vue's Dependency Injection, Angular's RxJS). It's not the case anymore since XState is framework agnostic, the media machine can be used in any UI frameworks with some more amount of code written for connector (XState has packages for Vue, React and more FYI 😋) I will start porting the solution into Vue one day for sure 😇. Good bye for now and until next time guys 🤘
❤️ ❤️ ❤️ Be well Be happy ❤️ ❤️ ❤️