11"use client" ;
22
33import { usePathname , useRouter } from "next/navigation" ;
4- import { useMemo , useState } from "react" ;
4+ import { useOptimistic , useState , useTransition } from "react" ;
55import { Pagination } from "@/components/ui/Pagination" ;
66import { Container } from "@/components/ui/SectionHeading" ;
77import { StoryCard } from "@/components/ui/StoryCard" ;
@@ -12,9 +12,9 @@ import {
1212} from "@/lib/data/fact-check-filters" ;
1313import { FILTERS , type FilterDimension } from "@/lib/fact-checks-content" ;
1414import type { Story } from "@/lib/home-content" ;
15- import { FilterBar , type Selection } from "./FilterBar" ;
15+ import { FilterBar } from "./FilterBar" ;
1616
17- function clone ( sel : Selection ) : Selection {
17+ function clone ( sel : FilterSelection ) : FilterSelection {
1818 return {
1919 region : [ ...sel . region ] ,
2020 language : [ ...sel . language ] ,
@@ -26,11 +26,14 @@ function clone(sel: Selection): Selection {
2626 * The fact-checks grid + filter bar. Filtering and pagination are both
2727 * **server-side and URL-driven**: `stories` arrive already filtered/paged, and
2828 * every control navigates by mutating the query string (`?region=…&topic=…&page=N`),
29- * which re-runs the server fetch. `filters` is the applied selection parsed from
30- * the URL; `selected` is the reader's staged edits, committed via "Apply Filters".
29+ * which re-runs the server fetch.
3130 *
32- * The parent keys this component by the applied filters, so navigation remounts
33- * it and `selected` re-initializes from the new URL state.
31+ * Filters **auto-apply** — there's no "Apply" step. The URL (`filters` prop) is
32+ * the single source of truth; toggling an option, removing a chip, or clearing
33+ * navigates straight away. `useOptimistic` mirrors the change so the checkboxes
34+ * and chips update instantly while the server re-fetches the grid in a
35+ * transition. Filter changes use `replace` (so a burst of toggles doesn't spam
36+ * history); pagination uses `push`.
3437 */
3538export function FactChecksExplorer ( {
3639 stories,
@@ -45,66 +48,63 @@ export function FactChecksExplorer({
4548} ) {
4649 const router = useRouter ( ) ;
4750 const pathname = usePathname ( ) ;
51+ const [ , startTransition ] = useTransition ( ) ;
4852
49- const [ selected , setSelected ] = useState < Selection > ( ( ) => clone ( filters ) ) ;
53+ // Optimistic mirror of the applied (URL) filters — instant checkbox/chip
54+ // feedback; rebases to `filters` once the navigation transition resolves.
55+ const [ optimistic , setOptimistic ] = useOptimistic ( filters ) ;
5056 const [ openDropdown , setOpenDropdown ] = useState < FilterDimension | null > (
5157 null ,
5258 ) ;
5359
54- const chips = useMemo (
55- ( ) =>
56- FILTERS . flatMap ( ( { dimension } ) =>
57- selected [ dimension ] . map ( ( value ) => ( { dimension, value } ) ) ,
58- ) ,
59- [ selected ] ,
60+ const chips = FILTERS . flatMap ( ( { dimension } ) =>
61+ optimistic [ dimension ] . map ( ( value ) => ( { dimension, value } ) ) ,
6062 ) ;
6163
62- const toggleOption = ( dimension : FilterDimension , value : string ) => {
63- setSelected ( ( prev ) => {
64- const next = clone ( prev ) ;
65- next [ dimension ] = next [ dimension ] . includes ( value )
66- ? next [ dimension ] . filter ( ( v ) => v !== value )
67- : [ ...next [ dimension ] , value ] ;
68- return next ;
64+ // Commit a filter/page change to the URL; the server re-fetches that slice.
65+ const navigate = (
66+ next : FilterSelection ,
67+ nextPage : number ,
68+ mode : "push" | "replace" ,
69+ ) => {
70+ const params = new URLSearchParams ( filtersToQuery ( next ) ) ;
71+ if ( nextPage > 1 ) params . set ( "page" , String ( nextPage ) ) ;
72+ const qs = params . toString ( ) ;
73+ const url = qs ? `${ pathname } ?${ qs } ` : pathname ;
74+ startTransition ( ( ) => {
75+ setOptimistic ( next ) ;
76+ router [ mode ] ( url ) ;
6977 } ) ;
7078 } ;
7179
72- // Removing a chip commits immediately (like Clear, but for one value) rather
73- // than only un-staging it — the listing re-queries on the spot. `setSelected`
74- // gives instant visual feedback; `navigate` re-fetches and the remount then
75- // re-syncs `selected` from the new URL.
76- const removeChip = ( dimension : FilterDimension , value : string ) => {
77- const next = clone ( selected ) ;
78- next [ dimension ] = next [ dimension ] . filter ( ( v ) => v !== value ) ;
79- setSelected ( next ) ;
80- navigate ( next , 1 ) ;
81- } ;
80+ // Any filter change auto-applies and resets to page 1.
81+ const commitFilters = ( next : FilterSelection ) => navigate ( next , 1 , "replace" ) ;
8282
83- const toggleDropdown = ( dimension : FilterDimension ) =>
84- setOpenDropdown ( ( cur ) => ( cur === dimension ? null : dimension ) ) ;
85-
86- // Navigate to a new filter+page combination; the server re-fetches that slice.
87- const navigate = ( nextFilters : FilterSelection , nextPage : number ) => {
88- const params = new URLSearchParams ( filtersToQuery ( nextFilters ) ) ;
89- if ( nextPage > 1 ) params . set ( "page" , String ( nextPage ) ) ;
90- const qs = params . toString ( ) ;
91- router . push ( qs ? `${ pathname } ?${ qs } ` : pathname ) ;
83+ const toggleOption = ( dimension : FilterDimension , value : string ) => {
84+ const next = clone ( optimistic ) ;
85+ next [ dimension ] = next [ dimension ] . includes ( value )
86+ ? next [ dimension ] . filter ( ( v ) => v !== value )
87+ : [ ...next [ dimension ] , value ] ;
88+ commitFilters ( next ) ;
9289 } ;
9390
94- // Pagination keeps the applied filters; only the page changes.
95- const goToPage = ( next : number ) => navigate ( filters , next ) ;
96-
97- const apply = ( ) => {
98- setOpenDropdown ( null ) ;
99- navigate ( selected , 1 ) ;
91+ const removeChip = ( dimension : FilterDimension , value : string ) => {
92+ const next = clone ( optimistic ) ;
93+ next [ dimension ] = next [ dimension ] . filter ( ( v ) => v !== value ) ;
94+ commitFilters ( next ) ;
10095 } ;
10196
10297 const clear = ( ) => {
103- setSelected ( clone ( EMPTY_FILTERS ) ) ;
10498 setOpenDropdown ( null ) ;
105- navigate ( EMPTY_FILTERS , 1 ) ;
99+ commitFilters ( EMPTY_FILTERS ) ;
106100 } ;
107101
102+ const toggleDropdown = ( dimension : FilterDimension ) =>
103+ setOpenDropdown ( ( cur ) => ( cur === dimension ? null : dimension ) ) ;
104+
105+ // Pagination keeps the applied filters; only the page changes.
106+ const goToPage = ( next : number ) => navigate ( filters , next , "push" ) ;
107+
108108 const [ feature , secondary , ...grid ] = stories ;
109109
110110 return (
@@ -113,13 +113,12 @@ export function FactChecksExplorer({
113113 < section className = "bg-neutral-50" >
114114 < Container className = "py-14 lg:py-16" >
115115 < FilterBar
116- selected = { selected }
116+ selected = { optimistic }
117117 openDropdown = { openDropdown }
118118 chips = { chips }
119119 onToggleDropdown = { toggleDropdown }
120120 onToggleOption = { toggleOption }
121121 onRemoveChip = { removeChip }
122- onApply = { apply }
123122 onClear = { clear }
124123 />
125124 </ Container >
0 commit comments