resources/assets/js/hooks/webResourceHooks/singleResourceHook.ts   A
last analyzed

Complexity

Total Complexity 21
Complexity/F 5.25

Size

Lines of Code 223
Function Count 4

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 21
eloc 182
mnd 17
bc 17
fnc 4
dl 0
loc 223
rs 10
bpm 4.25
cpm 5.25
noi 0
c 0
b 0
f 0

4 Functions

Rating   Name   Duplication   Size   Complexity  
A singleResourceHook.ts ➔ doNothing 0 2 1
A singleResourceHook.ts ➔ reducer 0 35 4
F singleResourceHook.ts ➔ useResource 0 96 15
A singleResourceHook.ts ➔ initialState 0 11 1
1
import { Reducer, useCallback, useEffect, useReducer, useRef } from "react";
2
import {
3
  FetchError,
4
  getRequest,
5
  processJsonResponse,
6
  putRequest,
7
} from "../../helpers/httpRequests";
8
import { decrement, identity } from "../../helpers/queries";
9
import { Json, ResourceStatus } from "./types";
10
11
export interface ResourceState<T> {
12
  value: T;
13
  status: ResourceStatus;
14
  pendingCount: number;
15
  error: Error | FetchError | undefined;
16
  initialRefreshFinished: boolean;
17
}
18
19
export enum ActionTypes {
20
  GetStart = "GET_START",
21
  GetFulfill = "GET_FULFILL",
22
  GetReject = "GET_REJECT",
23
24
  UpdateStart = "UPDATE_START",
25
  UpdateFulfill = "UPDATE_FULFILL",
26
  UpdateReject = "UPDATE_REJECT",
27
}
28
29
export type GetStartAction = { type: ActionTypes.GetStart };
30
export type GetFulfillAction<T> = {
31
  type: ActionTypes.GetFulfill;
32
  payload: T;
33
};
34
export type GetRejectAction = {
35
  type: ActionTypes.GetReject;
36
  payload: Error | FetchError;
37
};
38
39
export type UpdateStartAction<T> = {
40
  type: ActionTypes.UpdateStart;
41
  meta: { item: T };
42
};
43
export type UpdateFulfillAction<T> = {
44
  type: ActionTypes.UpdateFulfill;
45
  payload: T;
46
  meta: { item: T };
47
};
48
export type UpdateRejectAction<T> = {
49
  type: ActionTypes.UpdateReject;
50
  payload: Error | FetchError;
51
  meta: { item: T };
52
};
53
export type AsyncAction<T> =
54
  | GetStartAction
55
  | GetFulfillAction<T>
56
  | GetRejectAction
57
  | UpdateStartAction<T>
58
  | UpdateFulfillAction<T>
59
  | UpdateRejectAction<T>;
60
61
export function initialState<T>(
62
  initialValue: T,
63
  doInitialRefresh: boolean,
64
): ResourceState<T> {
65
  return {
66
    value: initialValue,
67
    status: "initial",
68
    pendingCount: 0,
69
    error: undefined,
70
    initialRefreshFinished: !doInitialRefresh,
71
  };
72
}
73
74
export function reducer<T>(
75
  state: ResourceState<T>,
76
  action: AsyncAction<T>,
77
): ResourceState<T> {
78
  switch (action.type) {
79
    case ActionTypes.GetStart:
80
    case ActionTypes.UpdateStart: // TODO: For now GET and UPDATE actions can be treated the same. If we want to add optimistic updates, this can change.
81
      return {
82
        value: state.value,
83
        status: "pending",
84
        pendingCount: state.pendingCount + 1,
85
        error: undefined,
86
        initialRefreshFinished: state.initialRefreshFinished,
87
      };
88
    case ActionTypes.GetFulfill:
89
    case ActionTypes.UpdateFulfill:
90
      return {
91
        value: action.payload,
92
        status: state.pendingCount <= 1 ? "fulfilled" : "pending",
93
        pendingCount: decrement(state.pendingCount),
94
        error: undefined,
95
        initialRefreshFinished: true,
96
      };
97
    case ActionTypes.GetReject:
98
    case ActionTypes.UpdateReject:
99
      return {
100
        value: state.value,
101
        status: state.pendingCount <= 1 ? "rejected" : "pending",
102
        pendingCount: decrement(state.pendingCount),
103
        error: action.payload,
104
        initialRefreshFinished: true,
105
      };
106
    default:
107
      return state;
108
  }
109
}
110
111
function doNothing(): void {
112
  /* do nothing */
113
}
114
115
export type UseResourceReturnType<T> = {
116
  value: T;
117
  status: ResourceStatus;
118
  error: undefined | Error | FetchError;
119
  initialRefreshFinished: boolean; // Becomes true after the initial request is fulfilled or rejected. NOTE: if initial fetch is skipped, this will be set to true immediately.
120
  update: (newValue: T) => Promise<T>;
121
  refresh: () => Promise<T>;
122
};
123
124
export function useResource<T>(
125
  endpoint: string,
126
  initialValue: T,
127
  overrides?: {
128
    parseResponse?: (response: Json) => T; // Defaults to the identity function.
129
    skipInitialRefresh?: boolean; // Defaults to false. Override if you want to keep the initialValue until refresh is called manually.
130
    handleError?: (error: Error | FetchError) => void; // In addition to using the error returned by the hook, you may provide a callback called on every new error.
131
  },
132
): UseResourceReturnType<T> {
133
  const parseResponse = overrides?.parseResponse ?? identity;
134
  const doInitialRefresh = overrides?.skipInitialRefresh !== true;
135
  const handleError = overrides?.handleError ?? doNothing;
136
  const isSubscribed = useRef(true);
137
138
  const [state, dispatch] = useReducer<
139
    Reducer<ResourceState<T>, AsyncAction<T>>
140
  >(reducer, initialState(initialValue, doInitialRefresh));
141
142
  const refresh = useCallback(async (): Promise<T> => {
143
    dispatch({ type: ActionTypes.GetStart });
144
    let json: Json;
145
    try {
146
      json = await getRequest(endpoint).then(processJsonResponse);
147
    } catch (error) {
148
      if (isSubscribed.current) {
149
        dispatch({
150
          type: ActionTypes.GetReject,
151
          payload: error,
152
        });
153
        handleError(error);
154
      }
155
      throw error;
156
    }
157
    const responseValue = parseResponse(json) as T;
158
    if (isSubscribed.current) {
159
      dispatch({
160
        type: ActionTypes.GetFulfill,
161
        payload: responseValue,
162
      });
163
    }
164
    return responseValue;
165
  }, [endpoint, parseResponse, handleError]);
166
167
  const update = useCallback(
168
    async (newValue): Promise<T> => {
169
      dispatch({ type: ActionTypes.UpdateStart, meta: { item: newValue } });
170
      let json: Json;
171
      try {
172
        json = await putRequest(endpoint, newValue).then(processJsonResponse);
173
      } catch (error) {
174
        if (isSubscribed.current) {
175
          dispatch({
176
            type: ActionTypes.UpdateReject,
177
            payload: error,
178
            meta: { item: newValue },
179
          });
180
          handleError(error);
181
        }
182
        throw error;
183
      }
184
      const responseValue = parseResponse(json) as T;
185
      if (isSubscribed.current) {
186
        dispatch({
187
          type: ActionTypes.UpdateFulfill,
188
          payload: responseValue,
189
          meta: { item: newValue },
190
        });
191
      }
192
      return responseValue;
193
    },
194
    [endpoint, parseResponse, handleError],
195
  );
196
197
  // Despite the usual guidelines, this should only be reconsidered if endpoint changes.
198
  // Changing doInitialRefresh after the first run (or the refresh function) should not cause this to rerun.
199
  useEffect(() => {
200
    if (doInitialRefresh) {
201
      refresh().catch(doNothing);
202
    }
203
204
    // Unsubscribe from promises when this hook is unmounted.
205
    return (): void => {
206
      isSubscribed.current = false;
207
    };
208
209
    // eslint-disable-next-line react-hooks/exhaustive-deps
210
  }, [endpoint]);
211
212
  return {
213
    value: state.value,
214
    status: state.status,
215
    error: state.error,
216
    initialRefreshFinished: state.initialRefreshFinished,
217
    update,
218
    refresh,
219
  };
220
}
221
222
export default useResource;
223