Animating API Results (On a Budget)
Editor's Note: In this post, freelance developer Joey Anuff describes how he used Remix, Framer, and StepZen to develop layout animations without blowing past his API quotas. For the full code, visit Joey's GitHub repo at /januff/stepzen-youtube-data-api. And be sure to check out the app.
Developing layout animations without depleting my API quota - using Remix, Framer, StepZen
Here's a front-end combination I'll definitely use again: Remix's Resource Routes and Framer Motions's AnimatePresence
component. Resource Routes are arbitrary serverless endpoints, one of which I'm running at './resource' to serve placeholder results for a stepped API query (sequenced using StepZen.)
It's a fairly quota-expensive query, the kind you want to keep as far from your local dev server as possible–especially when you're fiddling with web animations, which often demand endless browser reloads to make presentable. But with my YouTube query mocked in a static Resource Route, duplicating Dev Ed's layout animations in his recent Awesome Filtering Animation with React Tutorial video was a lot less stressful.
I was just wrapping up Kent C. Dodd's epic six-hour Remix tutorial when Simo Edwin's React animation demo showed up in my YouTube feed. The last time I'd played with Framer Motion, I'd run into problems with my exit animations during big container swaps (like list changes and route changes) but I was relieved to see Ed adroitly handling exit animations towards the video's end.
With the last section of Dodd's mega Remix tutorial fresh in mind, it struck me that a Resource Route would be the perfect place to serve cut-and-pasted API results, safe to refetch a few million times while I messed around with animated layouts.
So that's what I did. I hopped over to my local StepZen dev server and copied my preferred test response: the top 5,000 highest rated comments on Adult Swim's YouTube channel.
And then pasted it into a Remix route I named resource.tsx
, a LoaderFunction
that always returns the same mock JSON response:
Easily testable in the browser at my /resource
route:
And just as easily fetched in my index page loader:
export const loader: LoaderFunction = async () => {
let res = await fetch('https://remix-resource-routes.vercel.app/resource')
let fakeData = await res.json()
console.log('fakeData from loader', fakeData)
...
Remember that your server-side console logs will show up in the terminal running your Remix dev server, not in the browser console.
With Ryan Florence's advocacy for API data pruning as an ideal server-side computation fresh in mind, I used the same index page LoaderFunction
to map my 50 sets of 100 comments into two sorted arrays: Most Liked and Most Replied, whose top 100s are returned to the client.
let commentsArray: any[] = []
fakeData.data.channelByQuery?.videos.map(video => {
video.comments.map(comment => {
commentsArray.push({
...comment,
videoTitle: video.videoTitle,
videoId: video.videoId,
videoThumbnail: video.videoThumbnail
})
})
})
// console.log('commentsArray', commentsArray)
let likeSorted = [...commentsArray].sort((a, b) => {
return b.likeCount - a.likeCount
})
// console.log('likeSorted', likeSorted)
let replySorted = [...commentsArray].sort((a, b) => {
return b.totalReplyCount - a.totalReplyCount
})
let mostLiked = likeSorted.slice(0, 100)
let mostReplied = replySorted.slice(0, 100)
return {mostLiked, mostReplied};
Both of which Remix easily provides to the index page component via its useLoaderData
hook:
export default function Index() {
const [liked, setLiked] = useState(true)
const {mostLiked, mostReplied} = useLoaderData();
useEffect(() => {}, [liked])
console.log('mostLiked comments from component', mostLiked)
console.log('mostReplied comments from component', mostReplied)
...
(Which you can console.log in the browser.)
Note the requirement of a key prop when rendering lists in React: when I originally chose YouTube API fields when designing my StepZen query, among the values I discarded were the comments' individual IDs, or anything else uniquely identifying I could use as a comment key (besides the comments themselves.) But updating my StepZen schema to include YouTube’s assigned comment ID was notably simple. Referring back to my unabridged API results in Postman, I saw that YouTube includes a comment's id in a few places:
Leaving me to make just two changes to my GraphQL file: adding a commentId
field to my Comment
type, and specifying its path in the setters arguments of my @rest
-powered commentsByVideoId
query:
...
type Comment {
commentId: String
textDisplay: String
authorDisplayName: String
authorProfileImageUrl: String
likeCount: Int
totalReplyCount: Int
}
type Query {
...
commentsByVideoId(videoId: String!): [Comment]
@rest(
endpoint: "https://youtube.googleapis.com/youtube/v3/commentThreads?key=$key&videoId=$videoId&part=snippet&order=relevance&maxResults=20"
configuration: "youtube_config"
resultroot: "items[].snippet"
setters: [
{ field: "commentId",
path: "topLevelComment.id" },
{ field: "textDisplay",
path: "topLevelComment.snippet.textDisplay" },
{ field: "authorDisplayName",
path: "topLevelComment.snippet.authorDisplayName" },
{ field: "authorProfileImageUrl",
path: "topLevelComment.snippet.authorProfileImageUrl" },
{ field: "likeCount",
path: "topLevelComment.snippet.likeCount" }
]
)
}
Now that I had my data properly keyed, duplicating Dev Ed's layout animation came down to correctly copying in a few crucial elements. Most important of which was starting with a similar grid-template-columns formula, which has the interesting auto-fit we're animating:
section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
column-gap: 1em;
row-gap: 1em;
}
The trick to getting all phases of your list items' animations firing correctly seems to be including layout
tags in the motion
-tagged container and children, and an imported AnimatePresence
component wrapping the iterable:
<motion.section layout>
<AnimatePresence>
{ liked ?
mostLiked.map((comment) => {
return <Comment
key={comment.commentId}
liked={liked}
comment={comment} />;
}) :
mostReplied.map((comment) => {
return <Comment
key={comment.commentId}
liked={liked}
comment={comment} />;
})
}
</AnimatePresence>
</motion.section>
Item-specific Framer-Motion animation declarations are duly relegated to your item component (in this case Comment
):
import { motion } from "framer-motion";
export function Comment({ comment, liked }) {
return (
<motion.article
layout
transition={{ duration: 0.8 }}
animate={{ x: 0, opacity: 1 }}
initial={{ x: 800, opacity: 0 }}
exit={{ x: -800, opacity: 0 }}
whileHover={{
background: `rgba(0, 0, 0, 0.3) url(${comment.videoThumbnail})`,
backgroundSize: "cover",
backgroundBlendMode: "multiply",
backgroundPosition: "center",
transition: { duration: 0.2 }
style={{
background: `rgba(0, 0, 0, 0.8) url(${comment.videoThumbnail})`,
backgroundSize: "cover",
backgroundBlendMode: "multiply",
backgroundPosition: "center",}}
}}>
<header dangerouslySetInnerHTML={{__html: comment.videoTitle }}>
</header>
<section>
<span dangerouslySetInnerHTML={{__html: comment.textDisplay }} />
–{comment.authorDisplayName}
</section>
<footer>
{ liked ?
`${Number(comment.likeCount).toLocaleString('en', {useGrouping:true})} Likes` :
`${Number(comment.totalReplyCount).toLocaleString('en', {useGrouping:true})} Replies`
}
</footer>
</motion.article>
);
}
Winding us up with an endlessly tweakable list re-order animation, gracefully transitioning a mess of CSS properties out of the box.
stepzen-youtube-data-api.vercel.app
Leaving me free, when ready, to swap in my active StepZen endpoint and operate with live API data.