A perfect combination: Resource Routes in Remix and the AnimatePresence
component in Framer. 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 was anything absolutely identifying I could use as a key. But updating my StepZen schema to include YouTube's original 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.
Leaving me free, when ready, to swap in my active StepZen endpoint and operate with live API data.