3
3
type SetStateAction ,
4
4
useCallback ,
5
5
useEffect ,
6
+ useRef ,
6
7
useState ,
7
8
} from 'react' ;
8
9
import { history } from '@edx/frontend-platform' ;
@@ -85,10 +86,13 @@ export const useLoadOnScroll = (
85
86
} ;
86
87
87
88
/**
88
- * Hook which stores state variables in the URL search parameters.
89
- *
90
- * It wraps useState with functions that get/set a query string
91
- * search parameter when returning/setting the state variable.
89
+ * Types used by the useListHelpers and useStateWithUrlSearchParam hooks.
90
+ */
91
+ export type FromStringFn < Type > = ( value : string | null ) => Type | undefined ;
92
+ export type ToStringFn < Type > = ( value : Type | undefined ) => string | undefined ;
93
+
94
+ /**
95
+ * Hook that stores/retrieves state variables using the URL search parameters.
92
96
*
93
97
* @param defaultValue: Type
94
98
* Returned when no valid value is found in the url search parameter.
@@ -101,26 +105,103 @@ export const useLoadOnScroll = (
101
105
export function useStateWithUrlSearchParam < Type > (
102
106
defaultValue : Type ,
103
107
paramName : string ,
104
- fromString : ( value : string | null ) => Type | undefined ,
105
- toString : ( value : Type ) => string | undefined ,
108
+ fromString : FromStringFn < Type > ,
109
+ toString : ToStringFn < Type > ,
106
110
) : [ value : Type , setter : Dispatch < SetStateAction < Type > > ] {
111
+ // STATE WORKAROUND:
112
+ // If we use this hook to control multiple state parameters on the same
113
+ // page, we can run into state update issues. Because our state variables
114
+ // are actually stored in setSearchParams, and not in separate variables like
115
+ // useState would do, the searchParams "previous" state may not be updated
116
+ // for sequential calls to returnSetter in the same render loop (like in
117
+ // SearchManager's clearFilters).
118
+ //
119
+ // One workaround could be to use window.location.search as the "previous"
120
+ // value when returnSetter constructs the new URLSearchParams. This works
121
+ // fine with BrowserRouter, but our test suite uses MemoryRouter, and that
122
+ // router doesn't store URL search params, cf
123
+ // https://github.com/remix-run/react-router/issues/9757
124
+ //
125
+ // So instead, we maintain a reference to the current useLocation()
126
+ // object, and use its search params as the "previous" value when
127
+ // initializing URLSearchParams.
128
+ const location = useLocation ( ) ;
129
+ const locationRef = useRef ( location ) ;
107
130
const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
131
+
108
132
const returnValue : Type = fromString ( searchParams . get ( paramName ) ) ?? defaultValue ;
109
- // Function to update the url search parameter
110
- const returnSetter : Dispatch < SetStateAction < Type > > = useCallback ( ( value : Type ) => {
111
- setSearchParams ( ( prevParams ) => {
112
- const paramValue : string = toString ( value ) ?? '' ;
113
- const newSearchParams = new URLSearchParams ( prevParams ) ;
114
- // If using the default paramValue, remove it from the search params.
115
- if ( paramValue === defaultValue ) {
133
+ // Update the url search parameter using:
134
+ type ReturnSetterParams = (
135
+ // a Type value
136
+ value ?: Type
137
+ // or a function that returns a Type from the previous returnValue
138
+ | ( ( value : Type ) => Type )
139
+ ) => void ;
140
+ const returnSetter : Dispatch < SetStateAction < Type > > = useCallback < ReturnSetterParams > ( ( value ) => {
141
+ setSearchParams ( ( /* prev */ ) => {
142
+ const useValue = value instanceof Function ? value ( returnValue ) : value ;
143
+ const paramValue = toString ( useValue ) ;
144
+ const newSearchParams = new URLSearchParams ( locationRef . current . search ) ;
145
+ // If the provided value was invalid (toString returned undefined)
146
+ // or the same as the defaultValue, remove it from the search params.
147
+ if ( paramValue === undefined || paramValue === defaultValue ) {
116
148
newSearchParams . delete ( paramName ) ;
117
149
} else {
118
150
newSearchParams . set ( paramName , paramValue ) ;
119
151
}
152
+
153
+ // Update locationRef
154
+ locationRef . current . search = newSearchParams . toString ( ) ;
155
+
120
156
return newSearchParams ;
121
157
} , { replace : true } ) ;
122
- } , [ setSearchParams ] ) ;
158
+ } , [ returnValue , setSearchParams ] ) ;
123
159
124
160
// Return the computed value and wrapped set state function
125
161
return [ returnValue , returnSetter ] ;
126
162
}
163
+
164
+ /**
165
+ * Helper hook for useStateWithUrlSearchParam<Type[]>.
166
+ *
167
+ * useListHelpers provides toString and fromString handlers that can:
168
+ * - split/join a list of values using a separator string, and
169
+ * - validate each value using the provided functions, omitting any invalid values.
170
+ *
171
+ * @param fromString
172
+ * Serialize a string to a Type, or undefined if not valid.
173
+ * @param toString
174
+ * Deserialize a Type to a string.
175
+ * @param separator : string to use when splitting/joining the types.
176
+ * Defaults value is ','.
177
+ */
178
+ export function useListHelpers < Type > ( {
179
+ fromString,
180
+ toString,
181
+ separator = ',' ,
182
+ } : {
183
+ fromString : FromStringFn < Type > ,
184
+ toString : ToStringFn < Type > ,
185
+ separator ?: string ;
186
+ } ) : [ FromStringFn < Type [ ] > , ToStringFn < Type [ ] > ] {
187
+ const isType = ( item : Type | undefined ) : item is Type => item !== undefined ;
188
+
189
+ // Split the given string with separator,
190
+ // and convert the parts to a list of Types, omiting any invalid Types.
191
+ const fromStringToList : FromStringFn < Type [ ] > = ( value : string ) => (
192
+ value
193
+ ? value . split ( separator ) . map ( fromString ) . filter ( isType )
194
+ : [ ]
195
+ ) ;
196
+ // Convert an array of Types to strings and join with separator.
197
+ // Returns undefined if the given list contains no valid Types.
198
+ const fromListToString : ToStringFn < Type [ ] > = ( value : Type [ ] ) => {
199
+ const stringValues = value . map ( toString ) . filter ( ( val ) => val !== undefined ) ;
200
+ return (
201
+ stringValues && stringValues . length
202
+ ? stringValues . join ( separator )
203
+ : undefined
204
+ ) ;
205
+ } ;
206
+ return [ fromStringToList , fromListToString ] ;
207
+ }
0 commit comments